Trog-Data-SQLite.t 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. #!/usr/bin/env perl
  2. # Trog::Data::SQLite answers get() with SQL where Trog::DataModule::filter()
  3. # answers it with grep. The interesting question is not whether the SQL runs,
  4. # it is whether the two agree -- so most of this file writes one corpus into
  5. # both models and asserts they return the same posts for the same query.
  6. use 5.040;
  7. use re '/aasx';
  8. use warnings FATAL => 'all';
  9. use FindBin;
  10. use FindBin::libs;
  11. use Test::More;
  12. use Test::MockModule qw{strict};
  13. use Test::Fatal qw{exception};
  14. our ( $REPO, $ROOT, $OLDCWD );
  15. BEGIN {
  16. require Cwd;
  17. require File::Copy;
  18. require File::Path;
  19. require File::Temp;
  20. $REPO = Cwd::abs_path("$FindBin::Bin/..");
  21. $OLDCWD = Cwd::getcwd();
  22. $ROOT = File::Temp::tempdir( 'tcms-datamodel-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
  23. # Both models resolve their storage relative to cwd, and Text::Xslate reads
  24. # $HOME once at load time. Both have to be settled before anything loads.
  25. $ENV{HOME} = $ROOT;
  26. File::Path::make_path("$ROOT/$_") for qw{config schema data/files logs};
  27. File::Copy::copy( "$REPO/schema/$_", "$ROOT/schema/$_" ) or die $! for qw{flatfile.schema sqlite.schema};
  28. File::Copy::copy( "$REPO/config/default.cfg", "$ROOT/config/default.cfg" ) or die $!;
  29. chdir($ROOT) or die "could not chdir to the sandbox: $!";
  30. }
  31. END {
  32. chdir($OLDCWD) if $OLDCWD;
  33. }
  34. require_ok('Trog::Data::SQLite') or BAIL_OUT("Can't find SUT");
  35. require_ok('Trog::Data::FlatFile') or BAIL_OUT("Can't find the model to compare against");
  36. require Trog::Config;
  37. require Trog::SQLite::TagIndex;
  38. # _fixup() decorates every post with the author's display name and avatar class,
  39. # which would otherwise drag the auth database in. Not what is under test.
  40. my $auth = Test::MockModule->new('Trog::Auth');
  41. $auth->redefine( username2display => sub { return "Display $_[0]" } );
  42. $auth->redefine( username2classname => sub { return "class-$_[0]" } );
  43. my $conf = Trog::Config::get();
  44. my $sqlite = Trog::Data::SQLite->new($conf);
  45. my $flat = Trog::Data::FlatFile->new($conf);
  46. #--------------------------------------------------------------------------
  47. # One corpus, written to both.
  48. #--------------------------------------------------------------------------
  49. my $t = 1700000000;
  50. sub post {
  51. my (%o) = @_;
  52. return {
  53. title => 'Untitled',
  54. data => 'nothing in particular',
  55. user => 'bob',
  56. author => 'bob',
  57. visibility => 'public',
  58. created => $t++,
  59. version => 0,
  60. form => 'blog.tx',
  61. method => 'GET',
  62. callback => 'Trog::Routes::HTML::posts',
  63. %o,
  64. # _process() pushes visibility into tags, so the corpus does too.
  65. tags => [ @{ $o{tags} // ['blog'] }, $o{visibility} // 'public' ],
  66. };
  67. }
  68. my @corpus = (
  69. post( id => 'p-public', title => 'The Quick Brown Fox', data => 'jumps over the lazy dog', tags => [qw{blog topbar}] ),
  70. post( id => 'p-private', title => 'Secret Plans', data => 'the mice will never know', visibility => 'private' ),
  71. post( id => 'p-unlisted', title => 'Unlisted Thing', data => 'findable but not listed', visibility => 'unlisted' ),
  72. post( id => 'p-ann', title => 'By Ann', data => 'ann wrote this one', user => 'ann', author => 'ann' ),
  73. post( id => 'p-micro', title => 'A Microblog', data => 'short and sweet', form => 'microblog.tx' ),
  74. post( id => 'p-series', title => 'A Series', data => 'holds other posts', form => 'series.tx', aclname => 'aseries', tags => [qw{series topbar}] ),
  75. post( id => 'p-slides', title => 'A Deck', data => [ 'slide one about foxes', 'slide two' ], form => 'presentation.tx' ),
  76. # What _process() leaves behind for a private post with an acl: the acl is
  77. # folded into tags and the acls field is deleted outright.
  78. post( id => 'p-acl', title => 'Members Only', data => 'for the club', visibility => 'private', tags => [qw{blog aseries}] ),
  79. );
  80. # A second version of one post, so that version handling is exercised.
  81. push( @corpus, post( id => 'p-public', title => 'The Quick Brown Fox (revised)', data => 'jumps over the lazy dog, again', tags => [qw{blog topbar}], version => 1, created => $t++ ) );
  82. foreach my $p (@corpus) {
  83. # Separate copies: FlatFile's index build decorates what it is handed.
  84. $sqlite->write( [ { %$p, tags => [ @{ $p->{tags} } ] } ] );
  85. $flat->write( [ { %$p, tags => [ @{ $p->{tags} } ] } ] );
  86. }
  87. # FlatFile snapshots the tag list at load time and filters queries against it in
  88. # place, so a tag it has not seen is deleted from the query rather than simply
  89. # unmatched. Refresh it, or the comparison measures that bug instead.
  90. {
  91. no warnings qw{once};
  92. @Trog::Data::FlatFile::tags = Trog::SQLite::TagIndex::tags();
  93. %Trog::Data::FlatFile::posts_by_tag = ();
  94. }
  95. sub ids_of {
  96. my (@posts) = @_;
  97. return [ sort map { "$_->{id}\@$_->{version}" } @posts ];
  98. }
  99. #--------------------------------------------------------------------------
  100. subtest 'the two models answer the same questions the same way' => sub {
  101. my @queries = (
  102. [ 'everything an admin can see' => { limit => 0, acls => ['admin'] } ],
  103. [ 'everything a visitor can see' => { limit => 0, acls => ['public'] } ],
  104. [ 'a visitor plus unlisted' => { limit => 0, acls => [qw{public unlisted}] } ],
  105. [ 'no acls at all' => { limit => 0, acls => [] } ],
  106. [ 'one tag' => { limit => 0, acls => ['admin'], tags => ['topbar'] } ],
  107. [ 'several tags' => { limit => 0, acls => ['admin'], tags => [qw{topbar series}] } ],
  108. [ 'a tag nothing has' => { limit => 0, acls => ['admin'], tags => ['nonesuch'] } ],
  109. [ 'excluding a tag' => { limit => 0, acls => ['admin'], exclude_tags => ['topbar'] } ],
  110. [ 'tags and exclude together' => { limit => 0, acls => ['admin'], tags => ['blog'], exclude_tags => ['topbar'] } ],
  111. [ 'by form' => { limit => 0, acls => ['admin'], form => 'microblog.tx' } ],
  112. [ 'by a form nothing has' => { limit => 0, acls => ['admin'], form => 'nonesuch.tx' } ],
  113. [ 'by author' => { limit => 0, acls => ['admin'], author => 'ann' } ],
  114. [ 'by id' => { limit => 0, acls => [qw{admin}], id => 'p-series' } ],
  115. [ 'by an id nothing has' => { limit => 0, acls => [qw{admin}], id => 'p-nonesuch' } ],
  116. [ 'by title' => { limit => 0, acls => [qw{admin}], title => 'A Deck' } ],
  117. [ 'by aclname' => { limit => 0, acls => [qw{admin}], aclname => 'aseries' } ],
  118. [ 'by acl membership' => { limit => 0, acls => ['aseries'] } ],
  119. [ 'a private id, as a visitor' => { limit => 0, acls => ['public'], id => 'p-private' } ],
  120. [ 'a private title, as a visitor' => { limit => 0, acls => ['public'], title => 'Members Only' } ],
  121. [ 'a private id, holding its acl' => { limit => 0, acls => ['aseries'], id => 'p-acl' } ],
  122. [ 'an unlisted id, as a visitor' => { limit => 0, acls => ['public'], id => 'p-unlisted' } ],
  123. [ 'an unlisted id, by its link' => { limit => 0, acls => [qw{public unlisted}], id => 'p-unlisted' } ],
  124. [ 'a public aclname, as a visitor' => { limit => 0, acls => ['public'], aclname => 'aseries' } ],
  125. [ 'an id with no acls at all' => { limit => 0, acls => [], id => 'p-public' } ],
  126. [ 'older than' => { limit => 0, acls => ['admin'], older => $corpus[4]{created} } ],
  127. [ 'newer than' => { limit => 0, acls => ['admin'], newer => $corpus[4]{created} } ],
  128. [ 'a specific version' => { limit => 0, acls => [qw{admin}], id => 'p-public', version => 0 } ],
  129. [ 'the other version' => { limit => 0, acls => [qw{admin}], id => 'p-public', version => 1 } ],
  130. );
  131. foreach my $case (@queries) {
  132. my ( $name, $query ) = @$case;
  133. my $want = ids_of( $flat->get(%$query) );
  134. my $got = ids_of( $sqlite->get(%$query) );
  135. is_deeply( $got, $want, $name ) or diag("flatfile: @$want\nsqlite: @$got");
  136. }
  137. };
  138. subtest 'both models paginate by offset' => sub {
  139. # Not compared page for page: the flat file model returns posts in readdir
  140. # order and this one by created, so which posts land on page two is allowed
  141. # to differ. What is not allowed to differ is that walking the pages visits
  142. # every post, exactly once -- which is the whole of what pagination promises.
  143. foreach my $case ( [ flatfile => $flat ], [ sqlite => $sqlite ] ) {
  144. my ( $name, $model ) = @$case;
  145. my @all = $model->get( limit => 0, acls => ['admin'] );
  146. my $limit = 3;
  147. my ( @walked, %seen );
  148. foreach my $page ( 1 .. 5 ) {
  149. my @got = $model->get( limit => $limit, page => $page, acls => ['admin'] );
  150. ok( scalar(@got) <= $limit, "$name: page $page holds no more than a page" );
  151. push( @walked, map { $_->{id} } @got );
  152. }
  153. is_deeply( [ sort @walked ], [ sort map { $_->{id} } @all ], "$name: the pages between them hold every post" );
  154. is( scalar( grep { $seen{$_}++ } @walked ), 0, "$name: and none of them twice" );
  155. }
  156. };
  157. subtest 'the id query really does return one post' => sub {
  158. # Guards the comparison above: two empty lists are also "the same".
  159. my @found = $sqlite->get( limit => 0, acls => [qw{admin}], id => 'p-series' );
  160. is( scalar(@found), 1, 'exactly one' );
  161. is( $found[0]{id}, 'p-series', 'and the right one' );
  162. is( $found[0]{form}, 'series.tx', 'decoded whole, not just its id' );
  163. my @admin = $sqlite->get( limit => 0, acls => ['admin'] );
  164. is( scalar(@admin), 8, 'an admin sees every post, each at one version' );
  165. # public, ann, micro, series and slides. Not the private one, not the
  166. # members-only one, and not the unlisted one -- unlisted is its own tag, and
  167. # a caller only holding 'public' does not hold it.
  168. my @public = $sqlite->get( limit => 0, acls => ['public'] );
  169. is( scalar(@public), 5, 'a visitor sees only what is tagged public' );
  170. my @plus = $sqlite->get( limit => 0, acls => [qw{public unlisted}] );
  171. is( scalar(@plus), 6, 'and the unlisted one once they hold that too' );
  172. };
  173. subtest 'a direct lookup still answers only to the acls' => sub {
  174. foreach my $case ( [ flatfile => $flat ], [ sqlite => $sqlite ] ) {
  175. my ( $name, $model ) = @$case;
  176. my $ids = sub {
  177. [ sort map { $_->{id} } $model->get( limit => 0, @_ ) ]
  178. };
  179. is_deeply( $ids->( acls => ['public'], id => 'p-private' ), [], "$name: a visitor cannot fetch a private post by id" );
  180. is_deeply( $ids->( acls => ['public'], title => 'Secret Plans' ), [], "$name: nor by title" );
  181. is_deeply( $ids->( acls => [], id => 'p-public' ), [], "$name: no acls fetch nothing, however public" );
  182. is_deeply( $ids->( acls => ['admin'], id => 'p-private' ), ['p-private'], "$name: an admin can" );
  183. is_deeply( $ids->( acls => ['public'], id => 'p-public' ), ['p-public'], "$name: a visitor still gets a public post" );
  184. is_deeply( $ids->( acls => ['aseries'], id => 'p-acl' ), ['p-acl'], "$name: and a member the post their acl names" );
  185. is_deeply( $ids->( acls => ['public'], id => 'p-unlisted' ), [], "$name: unlisted needs its own acl" );
  186. is_deeply( $ids->( acls => [qw{public unlisted}], id => 'p-unlisted' ), ['p-unlisted'], "$name: which a link to it carries" );
  187. }
  188. };
  189. subtest 'a caller who can see nothing is not queried for' => sub {
  190. my $mock = Test::MockModule->new('Trog::Data::SQLite');
  191. $mock->redefine( _dbh => sub { die "queried the database\n" } );
  192. is( exception { is_deeply( $sqlite->read( { limit => 0, acls => [] } ), [], 'no acls get nothing' ) }, undef, 'without asking the database' );
  193. is( exception { is_deeply( $sqlite->read( { limit => 0, acls => [], id => 'p-public' } ), [], 'nor by id' ) }, undef, 'nor for a direct lookup' );
  194. like( exception { $sqlite->read( { limit => 0, acls => ['public'] } ) }, qr/queried[ ]the[ ]database/, 'while a caller with acls is queried' );
  195. };
  196. subtest 'versions' => sub {
  197. my ($latest) = $sqlite->get( limit => 0, acls => [qw{admin}], id => 'p-public' );
  198. is( $latest->{version}, 1, 'the newest version is the one you get' );
  199. is( $latest->{title}, 'The Quick Brown Fox (revised)', 'with its own content' );
  200. is( $latest->{version_max}, 1, 'version_max is the newest' );
  201. is( $latest->{created}, $corpus[0]{created}, 'created is the post birthday, not the revision' );
  202. is( $latest->{modified}, $corpus[-1]{created}, 'modified is the revision' );
  203. my ($first) = $sqlite->get( limit => 0, acls => [qw{admin}], id => 'p-public', version => 0 );
  204. is( $first->{title}, 'The Quick Brown Fox', 'an older version can still be asked for' );
  205. is( $first->{version_max}, 1, 'and knows a newer one exists' );
  206. # raw is the history as stored, which is what bin/migrate.pl reads.
  207. my @raw = $sqlite->get( raw => 1, limit => 0 );
  208. is( scalar(@raw), 9, 'raw gets every version of everything' );
  209. ok( !exists $raw[0]{version_max}, 'and does not decorate them' );
  210. };
  211. subtest 'the like filter goes through FTS5' => sub {
  212. # The substring semantics filter() had, which is why the index is built with
  213. # the trigram tokenizer.
  214. my @cases = (
  215. [ 'quick' => ['p-public'] ],
  216. [ 'QUICK' => ['p-public'] ], # case insensitive
  217. [ 'own fox' => ['p-public'] ], # matches inside words
  218. [ 'lazy dog' => ['p-public'] ],
  219. [ 'Secret Plans' => ['p-private'] ], # title as well as body
  220. [ 'foxes' => ['p-slides'] ], # data stored as an array
  221. [ 'nonesuch' => [] ],
  222. [ '0' => [] ], # a term, not an absent one
  223. );
  224. foreach my $case (@cases) {
  225. my ( $term, $want ) = @$case;
  226. my @got = map { $_->{id} } $sqlite->get( limit => 0, acls => ['admin'], like => $term );
  227. is_deeply( [ sort @got ], [ sort @$want ], "like '$term'" );
  228. }
  229. # FTS5 query syntax in the search box is text to search for, not syntax.
  230. foreach my $hostile ( 'AND', '"', 'foo OR bar', '*', 'NEAR(a b)', "it's" ) {
  231. is( exception { $sqlite->get( limit => 0, acls => ['admin'], like => $hostile ) }, undef, "'$hostile' is a search term, not a query" );
  232. }
  233. # Trigram cannot index below three characters, so those take the LIKE path.
  234. my @short = map { $_->{id} } $sqlite->get( limit => 0, acls => ['admin'], like => 'ox' );
  235. is_deeply( [ sort @short ], [ sort qw{p-public p-slides} ], 'a two character term still matches, via LIKE' );
  236. # And LIKE's own wildcards are literal there too.
  237. is_deeply( [ $sqlite->get( limit => 0, acls => ['admin'], like => '%' ) ], [], "'%' matches nothing rather than everything" );
  238. };
  239. subtest 'ordering and pagination' => sub {
  240. # By created, which after the version rollup is the post's birthday -- the
  241. # same field the caller gets back, and the same one older/newer filter on.
  242. my @all = $sqlite->get( limit => 0, acls => ['admin'] );
  243. my @created = map { $_->{created} } @all;
  244. is_deeply( \@created, [ reverse sort { $a <=> $b } @created ], 'newest first' );
  245. my @page1 = $sqlite->get( limit => 3, page => 1, acls => ['admin'] );
  246. my @page2 = $sqlite->get( limit => 3, page => 2, acls => ['admin'] );
  247. is( scalar(@page1), 3, 'a page is a page long' );
  248. is( scalar(@page2), 3, 'and so is the next one' );
  249. my %seen = map { $_->{id} => 1 } @page1;
  250. is( scalar( grep { $seen{ $_->{id} } } @page2 ), 0, 'and they do not overlap' );
  251. my @limited = $sqlite->get( limit => 2, acls => ['admin'] );
  252. is( scalar(@limited), 2, 'a limit with no page still limits' );
  253. };
  254. subtest 'count, tags and delete' => sub {
  255. is( $sqlite->count(), 8, 'count counts posts, not versions' );
  256. my @tags = $sqlite->tags();
  257. ok( scalar( grep { $_ eq 'topbar' } @tags ), 'tags lists a tag in use' );
  258. ok( scalar( grep { $_ eq 'aseries' } @tags ), 'including an acl folded into tags' );
  259. ok( !scalar( grep { $_ eq 'nonesuch' } @tags ), 'and nothing else' );
  260. $sqlite->delete( { id => 'p-micro' } );
  261. is( $sqlite->count(), 7, 'delete removes the post' );
  262. # The version bookkeeping has to let go of it too, or the next post to
  263. # reuse that id inherits a pointer to a row that is gone.
  264. my $vdbh = Trog::SQLite::dbh( undef, 'data/posts.sqlite' );
  265. my ($stale) = $vdbh->selectrow_array(q{SELECT COUNT(*) FROM post_versions WHERE uuid = 'p-micro'});
  266. is( $stale, 0, 'and its version bookkeeping' );
  267. is_deeply( [ $sqlite->get( limit => 0, acls => [qw{admin}], id => 'p-micro' ) ], [], 'and it cannot be fetched' );
  268. # The schema's triggers own this, not the Perl.
  269. my $dbh = Trog::SQLite::dbh( undef, 'data/posts.sqlite' );
  270. my ($orphans) = $dbh->selectrow_array('SELECT COUNT(*) FROM post_tags WHERE post_id NOT IN (SELECT id FROM posts)');
  271. is( $orphans, 0, 'the tag index went with it' );
  272. my ($ghosts) = $dbh->selectrow_array(q{SELECT COUNT(*) FROM posts_fts WHERE posts_fts MATCH '"short and sweet"'});
  273. is( $ghosts, 0, 'and so did the search index' );
  274. };
  275. subtest 'writing' => sub {
  276. my $before = $sqlite->count();
  277. $sqlite->write( [ post( id => 'p-new', title => 'Brand New' ) ] );
  278. is( $sqlite->count(), $before + 1, 'a new post lands' );
  279. # (uuid, version) is unique, which is what stops two workers writing the
  280. # same version rather than one of them silently winning.
  281. # DBI's PrintError would announce the constraint failure on stderr as well,
  282. # and we are provoking it on purpose.
  283. my $clash = do {
  284. local $SIG{__WARN__} = sub { };
  285. exception { $sqlite->write( [ post( id => 'p-new', title => 'Clashing' ) ] ) };
  286. };
  287. like( $clash, qr/Could[ ]not[ ]write[ ]post[ ]'p-new'/, 'the same version twice is refused, loudly' );
  288. is( exception { $sqlite->write( [ post( id => 'p-new', title => 'Second Version', version => 1 ) ] ) }, undef, 'a new version is not' );
  289. my ($now) = $sqlite->get( limit => 0, acls => [qw{admin}], id => 'p-new' );
  290. is( $now->{title}, 'Second Version', 'and wins' );
  291. like( exception { $sqlite->write( [ { title => 'No Id' } ] ) }, qr/no[ ]id/i, 'a post with no id is refused' );
  292. my $count = $sqlite->count();
  293. ok( exception { $sqlite->write( [ post( id => 'p-half', title => 'Half' ), { title => 'No Id' } ] ) }, 'a batch with a post it refuses is refused' );
  294. is( $sqlite->count(), $count, 'and none of the batch lands' );
  295. # These are computed per query out of the version history; storing them
  296. # would bake one query's answer into the post forever.
  297. my $dbh = Trog::SQLite::dbh( undef, 'data/posts.sqlite' );
  298. my ($blob) = $dbh->selectrow_array("SELECT post_data FROM posts WHERE uuid='p-new' AND version=1");
  299. unlike( $blob, qr/version_max|"modified"|display_name|user_class/, 'derived fields are not stored' );
  300. };
  301. subtest 'tags a post might really have' => sub {
  302. my $dbh = Trog::SQLite::dbh( undef, 'data/posts.sqlite' );
  303. my $tags_of = sub {
  304. my ($id) = @_;
  305. my $rows = $dbh->selectall_arrayref(
  306. 'SELECT tag FROM post_tags t JOIN posts p ON p.id = t.post_id WHERE p.uuid = ? ORDER BY tag',
  307. { Slice => {} }, $id
  308. );
  309. return [ map { $_->{tag} } @$rows ];
  310. };
  311. # A post is storable whatever its tags look like. Refusing one is refusing
  312. # to store the post, which is never the right answer for an index.
  313. my %shapes = (
  314. 'p-tag-missing' => undef,
  315. 'p-tag-null' => undef,
  316. 'p-tag-empty' => [],
  317. 'p-tag-normal' => [qw{blog public}],
  318. 'p-tag-holed' => [ 'public', undef ],
  319. 'p-tag-numeric' => [ 1, 2 ],
  320. );
  321. foreach my $id ( sort keys %shapes ) {
  322. my $post = post( id => $id );
  323. if ( $id eq 'p-tag-missing' ) { delete $post->{tags} }
  324. else { $post->{tags} = $shapes{$id} }
  325. is( exception { $sqlite->write( [$post] ) }, undef, "a post with $id tags is storable" );
  326. }
  327. # The one that actually bit: Trog::DataModule::_process() pushed a post's
  328. # visibility into its tags without defaulting it first, so posts written
  329. # before that carry a null there, and a whole site's migration fell over on
  330. # the NOT NULL constraint rather than on anything wrong with the post.
  331. is_deeply( $tags_of->('p-tag-holed'), ['public'], 'a null in the array is left out of the index' );
  332. is_deeply( $tags_of->('p-tag-normal'), [qw{blog public}], 'and a normal one is indexed whole' );
  333. is_deeply( $tags_of->('p-tag-empty'), [], 'an empty list indexes nothing' );
  334. is_deeply( $tags_of->('p-tag-null'), [], 'and neither does a null tags field' );
  335. is_deeply( $tags_of->('p-tag-missing'), [], 'nor no tags field at all' );
  336. # A null tag matched nothing in the flat file model either, so leaving it
  337. # out of the index changes no answer.
  338. my @found = $sqlite->get( limit => 0, acls => ['public'], tags => ['public'] );
  339. ok( scalar( grep { $_->{id} eq 'p-tag-holed' } @found ), 'the post is still found by the tag it does have' );
  340. };
  341. subtest 'the triggers can be fixed after the fact' => sub {
  342. # Every trigger is dropped and recreated when the schema is applied, rather
  343. # than created IF NOT EXISTS. With IF NOT EXISTS a database that already
  344. # had one kept its old copy for good, so fixing a trigger would have fixed
  345. # it only for sites that had not run yet -- which is no use at all, since
  346. # the site that needs the fix is by definition one that has.
  347. my $dbh = Trog::SQLite::dbh( undef, 'data/posts.sqlite' );
  348. $dbh->do('DROP TRIGGER posts_tags_insert');
  349. $dbh->do( 'CREATE TRIGGER posts_tags_insert AFTER INSERT ON posts BEGIN INSERT INTO post_tags (post_id, tag) VALUES (new.id, %s); END;' =~ s/%s/'wrong'/r );
  350. # What a worker starting up does.
  351. Trog::Data::SQLite::_dbh();
  352. my $schema = File::Slurper::read_text('schema/sqlite.schema');
  353. $dbh->{sqlite_allow_multiple_statements} = 1;
  354. $dbh->do($schema);
  355. $dbh->{sqlite_allow_multiple_statements} = 0;
  356. # post() appends the visibility to the tags, so this one carries both.
  357. $sqlite->write( [ post( id => 'p-after-fix', tags => ['afterfix'] ) ] );
  358. my $rows = $dbh->selectall_arrayref(
  359. 'SELECT tag FROM post_tags t JOIN posts p ON p.id = t.post_id WHERE p.uuid = ? ORDER BY tag',
  360. { Slice => {} }, 'p-after-fix'
  361. );
  362. is_deeply( [ map { $_->{tag} } @$rows ], [qw{afterfix public}], 'reapplying the schema replaces a stale trigger' );
  363. unlike( join( ',', map { $_->{tag} } @$rows ), qr/wrong/, 'and the stale one stops firing' );
  364. };
  365. subtest 'indexing a custom field' => sub {
  366. # index_fields() logs what it did, and with no logger configured that comes
  367. # out on stderr with a Carp::Always trace behind it.
  368. local $SIG{__WARN__} = sub { };
  369. my $dbh = Trog::SQLite::dbh( undef, 'data/posts.sqlite' );
  370. # xinfo, because table_info does not list generated columns.
  371. my $columns = sub {
  372. return { map { $_->{name} => 1 } @{ $dbh->selectall_arrayref( 'PRAGMA table_xinfo(posts)', { Slice => {} } ) } };
  373. };
  374. my $indexes = sub {
  375. return { map { $_->{name} => 1 } @{ $dbh->selectall_arrayref( "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='posts'", { Slice => {} } ) } };
  376. };
  377. ok( !$columns->()->{cook_time}, 'the field has no column to start with' );
  378. $sqlite->index_fields(qw{cook_time});
  379. ok( $columns->()->{cook_time}, 'indexing a field gives it a column' );
  380. ok( $indexes->()->{'posts_custom_cook_time'}, 'and an index' );
  381. # The column is generated out of the blob, so it is populated for posts that
  382. # were written long before anybody asked for it.
  383. $sqlite->write( [ post( id => 'p-recipe', title => 'Soup', cook_time => '45 minutes' ) ] );
  384. my ($value) = $dbh->selectrow_array(q{SELECT cook_time FROM posts WHERE uuid = 'p-recipe'});
  385. is( $value, '45 minutes', 'and the column reads the value straight out of the post' );
  386. # And SQLite actually uses it, which is the entire point.
  387. my ($plan) = map { $_->{detail} } @{ $dbh->selectall_arrayref( q{EXPLAIN QUERY PLAN SELECT id FROM posts WHERE cook_time = '45 minutes'}, { Slice => {} } ) };
  388. like( $plan, qr/USING[ ]INDEX[ ]posts_custom_cook_time/, 'and searches it rather than every post' );
  389. is( exception { $sqlite->index_fields(qw{cook_time}) }, undef, 'asking twice is not an error' );
  390. # Reconciling against a set that no longer names it.
  391. $sqlite->index_fields(qw{serves});
  392. ok( !$indexes->()->{'posts_custom_cook_time'}, 'a field left out loses its index' );
  393. ok( $columns->()->{cook_time}, 'but keeps its column, which costs nothing' );
  394. ok( $indexes->()->{'posts_custom_serves'}, 'and the newly named one gains an index' );
  395. # Only ever ours.
  396. $sqlite->index_fields();
  397. ok( $indexes->()->{posts_created}, "the schema's own indexes are left alone" );
  398. ok( $indexes->()->{posts_title}, 'all of them' );
  399. # The name reaches SQLite as an identifier and a JSON path, neither of which
  400. # can be bound as a parameter, so it is checked here and not merely upstream.
  401. foreach my $hostile ( 'cook_time; DROP TABLE posts', "cook'time", 'Cook_Time', '1st_course', ( 'a' x 40 ), '', 'post_data' ) {
  402. is( exception { $sqlite->index_fields($hostile) }, undef, "'$hostile' is refused rather than run" );
  403. }
  404. ok( $indexes->()->{posts_created}, 'and the table is still there afterwards' );
  405. ok( !$indexes->()->{'posts_custom_post_data'}, 'and a schema column did not pick up an index of its own' );
  406. };
  407. # Last, so the post it adds changes no count asserted above.
  408. subtest 'aliases' => sub {
  409. my $aliased = post(
  410. id => 'p-aliased',
  411. title => 'Many Names',
  412. local_href => '/posts/p-aliased',
  413. method => 'GET',
  414. callback => 'Trog::Routes::HTML::posts',
  415. aliases => [qw{/one /two /three}],
  416. );
  417. $sqlite->write( [ { %$aliased, tags => [ @{ $aliased->{tags} } ] } ] );
  418. $flat->write( [ { %$aliased, tags => [ @{ $aliased->{tags} } ] } ] );
  419. my %want = map { $_ => '/posts/p-aliased' } qw{/one /two /three};
  420. foreach my $case ( [ flatfile => $flat ], [ sqlite => $sqlite ] ) {
  421. my ( $name, $model ) = @$case;
  422. my %got = $model->aliases();
  423. is_deeply( { map { $_ => $got{$_} } grep { exists $want{$_} } keys %got }, \%want, "$name: every alias of a post leads to it, not only the first" );
  424. }
  425. };
  426. done_testing();