Ver código fonte

Make search work on datasource-backed pages

You were right: it was being dropped on the floor.  _post_helper() hands the
reader's 'like' to the data model, which filters the datastore with it, and then
_datasource_posts() throws that result away and returns whatever the datasource
built -- posts which have never been near the datastore and so were never
filtered by anything.  Searching a page of libvirt guests did nothing at all,
and the same went for author, older and newer.

Add Trog::DataSource, which is what a datasource is: posts($series, $query)
required, with EDITABLE, filter($query, @posts), lang() and help() optional and
defaulted here.  _datasource_posts() now applies the source's filter, or the
default on its behalf, so the search reaches the posts on the page.

The default filter deliberately does not do the tag, acl or version filtering
Trog::DataModule::filter() also does.  Those are answers about the datastore and
are already settled before a datasource is reached -- the reader got to the
series page by holding its acls, and every synthesized post belongs to that
series -- so applying them again would at best be redundant.  At worst it would
blank the page: a series id would match no child, and an empty tag list matches
nothing, and nothing requires a datasource's posts to carry tags at all.

lang() and help() are advertised the same way a data model's are, because the
model's answer describes what it can do to the datastore and a datasource page
is not being served out of the datastore.  Virt says so, and searches what its
posts actually carry rather than what a stored post would: a guest's body is
empty and everything about it is in named fields, so the default would only ever
have matched the guest name.  Searching 'shut' for the stopped guests, or a
hypervisor's title for everything on it, is what somebody typing in that box on
that page is trying to do.

Also folds the datasource name check and the module load out of two copies in
Routes::HTML into for_type() and load().  The namespace restriction stays where
it was and matters as much: the name comes out of a sidecar, and a sidecar
cannot be allowed to make us require whatever it likes.

Not addressed: a datasource page still does not paginate.  A datasource runs
before the paginator's own variables are worked out, so there is nothing yet to
paginate against, and moving the hook is a bigger change than this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6Q1gRJ2Rr85S8FQ47EjnQ
George Baugh 1 mês atrás
pai
commit
c86c616b75
5 arquivos alterados com 586 adições e 39 exclusões
  1. 22 0
      Readme.md
  2. 180 0
      lib/Trog/DataSource.pm
  3. 45 0
      lib/Trog/DataSource/Virt.pm
  4. 52 39
      lib/Trog/Routes/HTML.pm
  5. 287 0
      t/Trog-DataSource.t

+ 22 - 0
Readme.md

@@ -131,6 +131,28 @@ Sometimes you want to consider something else authoritative that isn't a datamod
 
 * Virt - Talk to a libvirt HV to list guests.
 
+A post type names one in its sidecar with x-tcms-datasource, and a series of that
+type then lists whatever the source builds instead of posts somebody wrote.  The
+series is still an ordinary post; only its children are synthesized, and they are
+rebuilt on every view rather than stored.
+
+A source has to provide posts($series, $query).  It may also provide:
+
+* filter($query, @posts) - apply the reader's search to the posts it built.
+  Those posts have never been near the datastore, so the search the reader typed
+  would otherwise be answered against the datastore and then thrown away along
+  with the posts it filtered.  Trog::DataSource::filter is the default, and
+  searches a post's title and body; implement your own to search the fields your
+  posts actually carry, as Virt does for a guest's name, state and hypervisor.
+* lang() and help() - what the search box on such a page is searching, and where
+  to read about it.  The data model's answer describes the datastore, which is
+  not what these pages are serving.
+* EDITABLE - whether the wizard should generate an editor for the type.  A source
+  building its posts from somewhere else has nothing to edit, and saying nothing
+  means no.
+
+See lib/Trog/DataSource.pm for the whole contract.
+
 Components
 ==========
 Sometimes you re-use a template a lot. Sometimes it also needs special handling.

+ 180 - 0
lib/Trog/DataSource.pm

@@ -0,0 +1,180 @@
+package Trog::DataSource;
+
+use v5.36;
+use re '/aa';
+
+use Trog::DataModule ();
+use Trog::Log        qw{:all};
+
+=head1 Trog::DataSource
+
+What a datasource is, and the default answers for the parts of it a given
+datasource doesn't care to implement.
+
+A post type can say its posts come from somewhere other than the datastore --
+see Trog::DataSource::Virt, which builds them out of libvirt guests.  The series
+is still an ordinary post; only its children are synthesized, and they are
+rebuilt on every view rather than stored.
+
+Datasources are plain packages under this namespace, called as functions rather
+than methods, and named by a post type's sidecar in x-tcms-datasource.
+
+=head1 THE CONTRACT
+
+    posts($series, $query) = @posts       required
+
+The children of $series, as post hashrefs.  Enough of a post to survive being
+rendered like one -- see Trog::DataSource::Virt::_post_for for the shape.
+
+    EDITABLE                              optional constant
+
+Whether the wizard should generate an editor for a type drawn from this source.
+A source which builds its posts from somewhere else has nothing to edit, and
+saying nothing means no.
+
+    filter($query, @posts) = @posts       optional
+
+Apply the viewer's search to the synthesized posts.  Defaulted below; implement
+it to search the fields your posts actually carry.
+
+    lang() = STRING                       optional
+    help() = STRING                       optional
+
+What the search box is searching, and where to read about it, for a page drawn
+from this source.  Defaulted below.
+
+=head1 FUNCTIONS
+
+=head2 filter($query, @posts) = @posts
+
+The default search over synthesized posts: the query's C<like>, C<author>,
+C<older> and C<newer>, with the same meanings they have for stored posts.
+
+Deliberately not the tag, acl or version filtering Trog::DataModule::filter()
+also does.  Those are answers about the datastore, and they are already settled
+before a datasource is reached: the series carries the acls, the reader got to
+its page by holding them, and every synthesized post belongs to that series.
+Applying them again would at best be redundant, and at worst would blank the
+page for a source whose posts carry no tags -- which nothing requires them to.
+
+Nor does it paginate.  A datasource runs before the paginator's own variables
+are worked out, so there is nothing yet to paginate against.
+
+A source with more to search than a title should say so by implementing filter()
+rather than by hoping this one covers it.
+
+=cut
+
+sub filter ( $query, @posts ) {
+    $query //= {};
+
+    if ( length( $query->{like} // '' ) ) {
+        my $like = $query->{like};
+        @posts = grep { searchable_match( $like, $_->{title}, $_->{data} ) } @posts;
+    }
+
+    @posts = grep { ( $_->{user} // '' ) eq $query->{author} } @posts if $query->{author};
+
+    # Coerced to a number the same way Trog::DataModule::filter() does it, since
+    # this arrives straight off a query string.
+    if ( $query->{older} ) {
+        ( my $older = $query->{older} ) =~ s/[^0-9]//g;
+        @posts = grep { ( $_->{created} // 0 ) < $older } @posts if length $older;
+    }
+    if ( $query->{newer} ) {
+        ( my $newer = $query->{newer} ) =~ s/[^0-9]//g;
+        @posts = grep { ( $_->{created} // 0 ) > $newer } @posts if length $newer;
+    }
+
+    return @posts;
+}
+
+=head2 searchable_match($like, @values) = BOOL
+
+Whether any of @values contains $like, case insensitively.
+
+The same case insensitive substring test Trog::DataModule::filter() applies to a
+stored post's title and body, factored out so that a datasource can hand it the
+fields its own posts actually carry without having to restate what a search
+means.  Quotemeta'd, so a search for '*' looks for an asterisk.
+
+Anything in @values which isn't a plain string is skipped, so an arrayref body
+or a nested structure is passed over rather than matched against its address.
+
+=cut
+
+sub searchable_match ( $like, @values ) {
+    return 0 unless length( $like // '' );
+
+    foreach my $value (@values) {
+        next     if !defined $value || ref $value;
+        return 1 if $value =~ m/\Q$like\E/i;
+    }
+    return 0;
+}
+
+=head2 lang() = STRING
+
+What the search box searches on a page drawn from a datasource which hasn't said.
+
+Not the data model's answer: the model's query language describes what it can do
+to the datastore, and a datasource page is not being served out of the datastore.
+
+=cut
+
+sub lang { 'Case insensitive substring' }
+
+=head2 help() = STRING
+
+Where to read about it.
+
+=cut
+
+sub help { 'https://en.wikipedia.org/wiki/Substring' }
+
+=head2 for_type($form) = STRING or undef
+
+The datasource a post type names, or nothing when it names none.
+
+Checked against this namespace rather than requiring whatever we were told to:
+the name comes out of a sidecar, and a sidecar cannot be allowed to make us load
+an arbitrary module.  Returns the module name without loading it; see load().
+
+=cut
+
+sub for_type ( $form = '' ) {
+    return undef unless length( $form // '' );
+
+    my $meta   = Trog::DataModule::type_meta_for($form);
+    my $source = $meta->{'x-tcms-datasource'};
+    return undef unless $source && !ref $source;
+
+    if ( $source !~ m/^Trog::DataSource::\w+$/ ) {
+        WARN("Post type '$form' names a datasource '$source' which isn't one");
+        return undef;
+    }
+
+    return $source;
+}
+
+=head2 load($source) = BOOL
+
+Load a datasource named by for_type(), returning whether it worked.
+
+=cut
+
+sub load ($source) {
+    my $modpath = $source;
+    $modpath =~ s{::}{/}g;
+    $modpath .= '.pm';
+
+    local $@;
+    eval { require $modpath; 1 } or do {
+        WARN("Datasource '$source' will not load: $@");
+        return 0;
+    };
+
+    return 1;
+}
+
+1;

+ 45 - 0
lib/Trog/DataSource/Virt.pm

@@ -8,6 +8,7 @@ use Sys::Hostname::FQDN();
 use Sys::Virt();
 
 use Trog::Config();
+use Trog::DataSource();
 
 use Trog::Log qw{WARN INFO};
 
@@ -175,6 +176,50 @@ sub posts ( $series, $query ) {
     return @out;
 }
 
+=head2 filter($query, @posts) = @posts
+
+Apply the reader's search to the guest list.
+
+A guest post's body is empty -- everything about it is in named fields -- so the
+default, which searches a post's title and body, would only ever match the guest
+name.  Search what the page actually shows instead: the guest name, its state,
+and the hypervisor it is on.  Searching 'shut' for the stopped guests, or a
+hypervisor's title for everything on it, is the thing somebody typing in that
+box on this page is trying to do.
+
+Everything else about the query means what it does elsewhere, so it is left to
+Trog::DataSource::filter.
+
+=cut
+
+sub filter ( $query, @posts ) {
+    $query //= {};
+
+    if ( length( $query->{like} // '' ) ) {
+        my $like = $query->{like};
+        @posts = grep {
+            Trog::DataSource::searchable_match(
+                $like,
+                $_->{title}, $_->{domain}, $_->{state}, $_->{hypervisor_title}, $_->{hypervisor},
+            )
+        } @posts;
+    }
+
+    # Without like, since that one is answered above.
+    return Trog::DataSource::filter( { %$query, like => undef }, @posts );
+}
+
+=head2 lang() = STRING
+
+=head2 help() = STRING
+
+What the search box searches on a page of guests.
+
+=cut
+
+sub lang { 'Case insensitive substring of a guest name, state or hypervisor' }
+sub help { 'https://en.wikipedia.org/wiki/Substring' }
+
 sub _post_for ( $domain, $hypervisor, $series, $form ) {
     my $name = $domain->get_name();
     my $info = $domain->get_info();

+ 52 - 39
lib/Trog/Routes/HTML.pm

@@ -37,6 +37,7 @@ use Trog::FileHandler;
 use Trog::Themes;
 use Trog::Renderer;
 use Trog::Email;
+use Trog::DataSource;
 
 our $landing_page = 'default.tx';
 
@@ -372,12 +373,14 @@ sub index ( $query, $content = '', $i_styles = [], $i_scripts = [] ) {
     state $data;
     $data //= Trog::Data->new( Trog::Config::get() );
 
+    my ( $search_lang, $search_help ) = _search_language($query);
+
     return finish_render(
         $tmpl,
         {
             %$query,
-            search_lang  => $data->lang(),
-            search_help  => $data->help(),
+            search_lang  => $search_lang,
+            search_help  => $search_help,
             theme_dir    => Trog::Themes::td(),
             content      => $content,
             title        => $title,
@@ -867,17 +870,7 @@ to be.
 
 sub _datasource_editable ($module) {
     return 1 unless $module;
-
-    my $modpath = $module;
-    $modpath =~ s{::}{/}g;
-    $modpath .= '.pm';
-
-    local $@;
-    eval { require $modpath; 1 } or do {
-        WARN("Datasource '$module' will not load: $@");
-        return 1;
-    };
-
+    return 1 unless Trog::DataSource::load($module);
     return 1 unless $module->can('EDITABLE');
     return $module->EDITABLE ? 1 : 0;
 }
@@ -1597,46 +1590,66 @@ sub _datasource_posts ( $query, $posts ) {
     my $series = $query->{primary_post};
     return @$posts unless Ref::Util::is_hashref($series) && $series->{child_form};
 
-    my $meta   = Trog::DataModule::type_meta_for( $series->{child_form} );
-    my $source = $meta->{'x-tcms-datasource'};
-    return @$posts unless $source && !ref $source;
+    my $source = Trog::DataSource::for_type( $series->{child_form} );
+    return @$posts unless $source;
+    return @$posts unless Trog::DataSource::load($source);
 
-    # Named in a sidecar, so check it rather than requiring whatever we were
-    # told to.  The namespace restriction is the point: a sidecar cannot make
-    # us load an arbitrary module.
-    if ( $source !~ m/^Trog::DataSource::\w+$/ ) {
-        WARN("Post type '$series->{child_form}' names a datasource '$source' which isn't one");
+    my $builder = $source->can('posts');
+    if ( !$builder ) {
+        WARN("Datasource '$source' has no posts() to call");
         return @$posts;
     }
 
-    my $modpath = $source;
-    $modpath =~ s{::}{/}g;
-    $modpath .= '.pm';
-
     local $@;
-    eval { require $modpath; 1 } or do {
-        WARN("Datasource '$source' will not load: $@");
-        return @$posts;
-    };
-
-    no strict 'refs';
-    my $sub = "${source}::posts";
-    if ( !defined &{$sub} ) {
-        WARN("Datasource '$source' has no posts() to call");
+    my @synthesized = eval { $builder->( $series, $query ) };
+    if ($@) {
+        my $err = "$@";
+        $err =~ s/\n.*//s;
+        WARN("Datasource '$source' failed: $err");
         return @$posts;
     }
 
-    my @synthesized = eval { &{$sub}( $series, $query ) };
-    use strict;
+    # The search the reader typed was applied to the datastore by get(), and
+    # then thrown away with the posts it filtered -- these are built fresh and
+    # have never been near it.  Ask the source to apply it, or apply the default
+    # on its behalf, so that searching a datasource page searches the page.
+    my $filter = $source->can('filter') // \&Trog::DataSource::filter;
 
+    my @filtered = eval { $filter->( $query, @synthesized ) };
     if ($@) {
         my $err = "$@";
         $err =~ s/\n.*//s;
-        WARN("Datasource '$source' failed: $err");
-        return @$posts;
+        WARN("Datasource '$source' could not filter its posts: $err");
+        return @synthesized;
     }
 
-    return @synthesized;
+    return @filtered;
+}
+
+=head2 _search_language($query)
+
+What to tell the reader the search box searches, as (language, help link).
+
+The data model's answer describes what it can do to the datastore, which is the
+right answer for nearly every page.  It is the wrong one for a page whose posts
+are built by a datasource rather than read out of the datastore, so such a page
+gets to say so.
+
+=cut
+
+sub _search_language ($query) {
+    state $data;
+    $data //= Trog::Data->new( Trog::Config::get() );
+
+    my $series = $query->{primary_post};
+    my $source = Ref::Util::is_hashref($series) && $series->{child_form} ? Trog::DataSource::for_type( $series->{child_form} ) : undef;
+
+    return ( $data->lang(), $data->help() ) unless $source && Trog::DataSource::load($source);
+
+    my $lang = $source->can('lang') // \&Trog::DataSource::lang;
+    my $help = $source->can('help') // \&Trog::DataSource::help;
+
+    return ( scalar $lang->(), scalar $help->() );
 }
 
 sub _post_helper ( $query, $tags, $acls ) {

+ 287 - 0
t/Trog-DataSource.t

@@ -0,0 +1,287 @@
+#!/usr/bin/env perl
+
+# A datasource's posts are built fresh on every view and have never been near
+# the datastore, so the search the reader typed -- which get() applied to the
+# datastore and then threw away along with the posts it filtered -- has to be
+# applied to them by somebody.  This is that somebody.
+
+use strict;
+use warnings;
+
+use FindBin;
+use lib "$FindBin::Bin/../lib";
+
+use Test::More;
+use Test::Fatal qw{exception};
+
+require_ok('Trog::DataSource') or BAIL_OUT("Can't find SUT");
+
+my $t = 1700000000;
+
+sub guest {
+    my ( $title, %o ) = @_;
+    return {
+        id      => "id-$title",
+        title   => $title,
+        data    => '',
+        created => $t++,
+        user    => 'bob',
+        version => 0,
+        %o,
+    };
+}
+
+my @posts = (
+    guest( 'webserver', state => 'running',  hypervisor_title => 'Rack One' ),
+    guest( 'database',  state => 'shut off', hypervisor_title => 'Rack One' ),
+    guest( 'mailhost',  state => 'running',  hypervisor_title => 'Rack Two', user => 'ann' ),
+);
+
+sub titles {
+    my (@got) = @_;
+    return [ sort map { $_->{title} } @got ];
+}
+
+subtest 'the default filter searches title and body' => sub {
+    is_deeply( titles( Trog::DataSource::filter( {},    @posts ) ), titles(@posts), 'an empty query filters nothing' );
+    is_deeply( titles( Trog::DataSource::filter( undef, @posts ) ), titles(@posts), 'and neither does no query at all' );
+
+    is_deeply( titles( Trog::DataSource::filter( { like => 'web' },      @posts ) ), ['webserver'], 'a substring of the title matches' );
+    is_deeply( titles( Trog::DataSource::filter( { like => 'SERVER' },   @posts ) ), ['webserver'], 'case insensitively' );
+    is_deeply( titles( Trog::DataSource::filter( { like => 'erve' },     @posts ) ), ['webserver'], 'inside a word' );
+    is_deeply( titles( Trog::DataSource::filter( { like => 'nonesuch' }, @posts ) ), [],            'and nothing matches nothing' );
+
+    # The body counts too, for a source whose posts have one.
+    my @bodied = ( guest( 'notes', data => 'the quick brown fox' ) );
+    is_deeply( titles( Trog::DataSource::filter( { like => 'quick' }, @bodied ) ), ['notes'], 'the body is searched as well' );
+
+    # The default does NOT know about a source's own fields -- that is what a
+    # source implementing filter() is for.
+    is_deeply( titles( Trog::DataSource::filter( { like => 'Rack One' }, @posts ) ), [], 'but a field it has never heard of is not' );
+};
+
+subtest 'the rest of the query still means what it means' => sub {
+    is_deeply( titles( Trog::DataSource::filter( { author => 'ann' },    @posts ) ), ['mailhost'], 'author filters on the post user' );
+    is_deeply( titles( Trog::DataSource::filter( { author => 'nobody' }, @posts ) ), [],           'and an author with nothing gets nothing' );
+
+    is_deeply( titles( Trog::DataSource::filter( { older => $posts[1]{created} }, @posts ) ), ['webserver'], 'older is older' );
+    is_deeply( titles( Trog::DataSource::filter( { newer => $posts[1]{created} }, @posts ) ), ['mailhost'],  'and newer is newer' );
+
+    # Straight off a query string, so it is coerced rather than trusted.
+    is_deeply(
+        titles( Trog::DataSource::filter( { older => "$posts[1]{created}abc" }, @posts ) ),
+        ['webserver'],
+        'a non-numeric older is coerced rather than dying'
+    );
+
+    # Combined, the way a search on a filtered page arrives.
+    is_deeply( titles( Trog::DataSource::filter( { like => 'host', author => 'ann' }, @posts ) ), ['mailhost'], 'filters compose' );
+    is_deeply( titles( Trog::DataSource::filter( { like => 'host', author => 'bob' }, @posts ) ), [],           'and disagree when they should' );
+};
+
+subtest 'what the default deliberately does not do' => sub {
+
+    # Tags and acls are answers about the datastore, and they are settled before
+    # a datasource is reached -- the reader got to the series page by holding
+    # its acls, and every synthesized post belongs to that series.  Applying
+    # them again would blank the page for a source whose posts carry no tags,
+    # which nothing requires them to.
+    is_deeply( titles( Trog::DataSource::filter( { acls => [] },           @posts ) ), titles(@posts), 'an empty acl list does not blank the page' );
+    is_deeply( titles( Trog::DataSource::filter( { tags => ['nonesuch'] }, @posts ) ), titles(@posts), 'nor does a tag nothing carries' );
+
+    # An id query would otherwise match the series, not its children, and leave
+    # nothing at all.
+    is_deeply( titles( Trog::DataSource::filter( { id => 'the-series' }, @posts ) ), titles(@posts), 'and neither does the series id' );
+};
+
+subtest 'searchable_match' => sub {
+    ok( Trog::DataSource::searchable_match( 'ell', 'hello' ),       'a substring matches' );
+    ok( Trog::DataSource::searchable_match( 'ELL', 'hello' ),       'case insensitively' );
+    ok( Trog::DataSource::searchable_match( 'x', 'a', undef, 'x' ), 'across several values' );
+    ok( !Trog::DataSource::searchable_match( 'zz', 'a', 'b' ),      'and misses when it should' );
+
+    ok( !Trog::DataSource::searchable_match( '',    'anything' ), 'an empty search matches nothing' );
+    ok( !Trog::DataSource::searchable_match( undef, 'anything' ), 'and neither does no search' );
+
+    # Quotemeta'd, so a search for punctuation is a search for punctuation.
+    ok( !Trog::DataSource::searchable_match( '.*', 'anything' ), 'a regex is text, not a pattern' );
+    ok( Trog::DataSource::searchable_match( '.*',  'a .* b' ),   'and matches where that text is' );
+    is( exception { Trog::DataSource::searchable_match( '(unclosed', 'x' ) }, undef, 'even when it would not compile' );
+
+    # A body stored as an array would otherwise be matched against its address.
+    ok( !Trog::DataSource::searchable_match( 'ARRAY', ['slide one'] ), 'a reference is skipped, not stringified' );
+};
+
+subtest 'for_type' => sub {
+    is( Trog::DataSource::for_type(),          undef, 'no type names no source' );
+    is( Trog::DataSource::for_type(''),        undef, 'and neither does an empty one' );
+    is( Trog::DataSource::for_type('blog.tx'), undef, 'a type with no datasource names none' );
+};
+
+subtest 'the defaults are advertised' => sub {
+    ok( length Trog::DataSource::lang(), 'there is a language to advertise' );
+    like( Trog::DataSource::help(), qr{^https?://}, 'and somewhere to read about it' );
+};
+
+subtest 'Trog::DataSource::Virt searches what its posts carry' => sub {
+    require_ok('Trog::DataSource::Virt') or return;
+
+    # A guest post's body is empty; everything about it is in named fields.
+    is_deeply( titles( Trog::DataSource::Virt::filter( { like => 'web' },      @posts ) ), ['webserver'],                   'the guest name is searched' );
+    is_deeply( titles( Trog::DataSource::Virt::filter( { like => 'shut' },     @posts ) ), ['database'],                    'and so is its state' );
+    is_deeply( titles( Trog::DataSource::Virt::filter( { like => 'Rack One' }, @posts ) ), [ sort qw{database webserver} ], 'and the hypervisor it is on' );
+    is_deeply( titles( Trog::DataSource::Virt::filter( { like => 'nonesuch' }, @posts ) ), [],                              'and nothing matches nothing' );
+
+    # The rest of the query is still the default's job.
+    is_deeply( titles( Trog::DataSource::Virt::filter( { author => 'ann' },                      @posts ) ), ['mailhost'], 'author still filters' );
+    is_deeply( titles( Trog::DataSource::Virt::filter( { like   => 'running', author => 'ann' }, @posts ) ), ['mailhost'], 'alongside the search' );
+
+    # It says so, rather than letting the page advertise the data model's.
+    isnt( Trog::DataSource::Virt::lang(), Trog::DataSource::lang(), 'and it describes its own search' );
+    like( Trog::DataSource::Virt::lang(), qr/guest/i, 'in terms of what is on the page' );
+};
+
+#--------------------------------------------------------------------------
+# The wiring: a route has to actually reach for all this.
+#--------------------------------------------------------------------------
+
+BEGIN {
+    $INC{'Trog/DataSource/TestPlain.pm'}  = 1;
+    $INC{'Trog/DataSource/TestPicky.pm'}  = 1;
+    $INC{'Trog/DataSource/TestBroken.pm'} = 1;
+}
+
+{
+    # A source with no filter() of its own: the default has to be applied for it.
+    package Trog::DataSource::TestPlain;
+    sub posts { return ( main::guest('alpha'), main::guest('beta') ) }
+
+    # One with opinions, including its own search language.
+    package Trog::DataSource::TestPicky;
+    our @FILTERED;
+    sub posts { return ( main::guest('alpha'), main::guest('beta') ) }
+
+    sub filter {
+        my ( $q, @p ) = @_;
+        push @FILTERED, $q->{like};
+        return grep { $_->{title} eq 'beta' } @p;
+    }
+    sub lang { 'Picky search' }
+    sub help { 'https://example.com/picky' }
+
+    # One whose filter blows up.
+    package Trog::DataSource::TestBroken;
+    sub posts  { return ( main::guest('alpha') ) }
+    sub filter { die "no\n" }
+}
+
+subtest 'the route applies the search to synthesized posts' => sub {
+    require Trog::Routes::HTML;
+    require Test::MockModule;
+
+    my $meta = Test::MockModule->new('Trog::DataModule');
+    my $source;
+    $meta->redefine( type_meta_for => sub { return $source ? { 'x-tcms-datasource' => $source } : {} } );
+
+    my $query = sub { return { primary_post => { child_form => 'fake.tx' }, @_ } };
+
+    # No datasource at all: the stored posts are handed straight back.
+    $source = undef;
+    my @stored = ( guest('stored') );
+    is_deeply( titles( Trog::Routes::HTML::_datasource_posts( $query->(), \@stored ) ), ['stored'], 'an ordinary type is left alone' );
+
+    $source = 'Trog::DataSource::TestPlain';
+    is_deeply( titles( Trog::Routes::HTML::_datasource_posts( $query->(), \@stored ) ), [ sort qw{alpha beta} ], 'a datasource replaces them' );
+
+    # The point of the exercise.
+    is_deeply(
+        titles( Trog::Routes::HTML::_datasource_posts( $query->( like => 'alph' ), \@stored ) ),
+        ['alpha'],
+        'and the search reaches them, rather than being dropped'
+    );
+    is_deeply(
+        titles( Trog::Routes::HTML::_datasource_posts( $query->( like => 'nonesuch' ), \@stored ) ),
+        [],
+        'including when it matches nothing'
+    );
+
+    # A source with its own filter gets to use it.
+    $source                                = 'Trog::DataSource::TestPicky';
+    @Trog::DataSource::TestPicky::FILTERED = ();
+    is_deeply(
+        titles( Trog::Routes::HTML::_datasource_posts( $query->( like => 'alph' ), \@stored ) ),
+        ['beta'],
+        "a source's own filter wins over the default"
+    );
+    is_deeply( \@Trog::DataSource::TestPicky::FILTERED, ['alph'], 'and it is handed the query' );
+
+    # A filter that dies must not take the page with it.
+    $source = 'Trog::DataSource::TestBroken';
+    my @survived;
+    {
+        my $log = Test::MockModule->new('Trog::Routes::HTML');
+        $log->redefine( WARN => sub { note(shift) } );
+        @survived = Trog::Routes::HTML::_datasource_posts( $query->( like => 'x' ), \@stored );
+    }
+    is_deeply( titles(@survived), ['alpha'], 'a filter that dies falls back to the unfiltered posts' );
+
+    # A sidecar cannot make us load whatever it likes.
+    $source = 'Evil::Module';
+    my @refused;
+    {
+        my $log = Test::MockModule->new('Trog::DataSource');
+        $log->redefine( WARN => sub { note(shift) } );
+        @refused = Trog::Routes::HTML::_datasource_posts( $query->(), \@stored );
+    }
+    is_deeply( titles(@refused), ['stored'], 'a datasource outside the namespace is refused' );
+};
+
+subtest 'the search box says what it is searching' => sub {
+    require Trog::Routes::HTML;
+    require Test::MockModule;
+
+    # Stubbed rather than built: constructing a real model here would create a
+    # datastore in the repo, and none of this is about which model is configured.
+    my $data = Test::MockModule->new('Trog::Data');
+    $data->redefine( new => sub { return bless {}, 'FakeModel' } );
+
+    {
+        no warnings qw{once};
+        *FakeModel::lang = sub { 'Model language' };
+        *FakeModel::help = sub { 'https://example.com/model' };
+    }
+
+    my $meta = Test::MockModule->new('Trog::DataModule');
+    my $source;
+    $meta->redefine( type_meta_for => sub { return $source ? { 'x-tcms-datasource' => $source } : {} } );
+
+    $source = undef;
+    is_deeply(
+        [ Trog::Routes::HTML::_search_language( { primary_post => { child_form => 'blog.tx' } } ) ],
+        [ 'Model language', 'https://example.com/model' ],
+        'an ordinary page advertises the data model'
+    );
+    is_deeply(
+        [ Trog::Routes::HTML::_search_language( {} ) ],
+        [ 'Model language', 'https://example.com/model' ],
+        'and so does a page with no series at all'
+    );
+
+    # A datasource page is not being served out of the datastore, so the model's
+    # query language is the wrong thing to advertise on it.
+    $source = 'Trog::DataSource::TestPlain';
+    is_deeply(
+        [ Trog::Routes::HTML::_search_language( { primary_post => { child_form => 'fake.tx' } } ) ],
+        [ Trog::DataSource::lang(), Trog::DataSource::help() ],
+        'a datasource page advertises the datasource default'
+    );
+
+    $source = 'Trog::DataSource::TestPicky';
+    is_deeply(
+        [ Trog::Routes::HTML::_search_language( { primary_post => { child_form => 'fake.tx' } } ) ],
+        [ 'Picky search', 'https://example.com/picky' ],
+        'or its own, when it has one'
+    );
+};
+
+done_testing();