SQLite.pm 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. package Trog::Data::SQLite;
  2. use 5.040;
  3. use re '/aasx';
  4. use Carp qw{confess};
  5. use JSON::MaybeXS;
  6. use List::Util qw{any};
  7. use lib 'lib';
  8. use Trog::Log qw{:all};
  9. use Trog::SQLite;
  10. use parent qw{Trog::DataModule};
  11. our $schema = 'schema/sqlite.schema';
  12. our $dbname = 'data/posts.sqlite';
  13. our $parser = JSON::MaybeXS->new( utf8 => 1 );
  14. sub lang { 'SQLite FTS5 substring match' }
  15. sub help { 'https://sqlite.org/fts5.html' }
  16. =head1 Trog::Data::SQLite
  17. The posts themselves in SQLite, rather than a flat file per post with SQLite
  18. kept alongside as an index of it.
  19. Each row is one version of one post: the post's JSON exactly as the flat file
  20. model would have written it, in C<post_data>, and nothing else that isn't
  21. C<GENERATED ALWAYS ... VIRTUAL> out of that blob. The blob stays the single
  22. source of truth, the projections cost no storage, and they cannot drift from
  23. what they were drawn from.
  24. What that buys is get(): the filtering Trog::DataModule::filter() does in Perl
  25. over every post on disk happens here in one indexed query. See _where() for
  26. the mapping, which is filter() clause for clause.
  27. Adding a queryable field is one ALTER TABLE and one CREATE INDEX, with no
  28. migration of the data, because the data itself never moves. They go in the
  29. next update file beside the schema, such as
  30. schema/sqlite.schema.v1.update.sql, and not in schema/sqlite.schema itself.
  31. Trog::SQLite applies the schema on every new handle, and an ALTER TABLE fails
  32. the second time. It runs each update file once per database, in order.
  33. =head1 Termination Conditions
  34. Dies if the database can't be opened or the schema can't be applied, both of
  35. which mean there is no data model to serve from.
  36. =cut
  37. sub _dbh {
  38. return Trog::SQLite::dbh( $schema, $dbname );
  39. }
  40. =head1 QUERYING
  41. =head2 get(%request)
  42. Trog::DataModule::get() without the filtering pass: the query does that.
  43. Everything filter() would have grepped for is a WHERE clause, and paginate()'s
  44. slice is LIMIT/OFFSET, so what comes back out of the database is already the
  45. answer. _fixup() still runs, as that fills in display fields rather than
  46. choosing rows.
  47. =cut
  48. sub get ( $self, %request ) {
  49. my $posts = $self->read( \%request );
  50. return @$posts if $request{raw};
  51. return $self->_fixup(@$posts);
  52. }
  53. =head2 read($query) = ARRAYREF $posts
  54. The rows a query selects, decoded back into posts.
  55. With C<raw>, every version of everything the query matches, untouched -- that is
  56. what an index build or a version history wants. Otherwise one post per id: the
  57. newest version, carrying the version_max, created, modified and author fields
  58. that Trog::DataModule::_dedup_versions() would have computed by hand.
  59. =cut
  60. sub read ( $self, $query = {} ) {
  61. my ( $sql, @bind ) = _build_query($query) or return [];
  62. my $rows = _dbh()->selectall_arrayref( $sql, { Slice => {} }, @bind );
  63. return [] unless ref $rows eq 'ARRAY';
  64. my @posts;
  65. foreach my $row (@$rows) {
  66. my $post = eval { $parser->decode( $row->{post_data} ) };
  67. if ( !$post ) {
  68. WARN("Could not decode post '$row->{uuid}' version $row->{version}: $@");
  69. next;
  70. }
  71. if ( !$query->{raw} ) {
  72. # What _dedup_versions() works out by sorting the versions in Perl.
  73. # Deliberately the same shape, so a caller cannot tell which data
  74. # model answered it.
  75. $post->{version_max} = $row->{version_max};
  76. # Only when we were not asked for one version in particular.
  77. # _dedup_versions() rewrites these from the version history, and a
  78. # request for version 3 wants version 3's own dates, not the post's.
  79. if ( !_pinned($query) ) {
  80. $post->{modified} = $post->{created};
  81. $post->{created} = $row->{first_created};
  82. $post->{author} = $row->{first_author};
  83. }
  84. }
  85. push( @posts, $post );
  86. }
  87. return \@posts;
  88. }
  89. # One version of one post per row, so "the current post" is a join against
  90. # post_versions, which the schema's triggers keep pointing at the newest row for
  91. # each uuid. f is the oldest version, which is where _dedup_versions() takes
  92. # the post's own birthday and original author from.
  93. sub _from_clause {
  94. return q{
  95. FROM posts p
  96. JOIN post_versions v ON v.uuid = p.uuid
  97. JOIN posts f ON f.id = v.first_id
  98. };
  99. }
  100. # Whether the caller named a version. Empty string counts as unasked, since
  101. # that is what a form posts for a field nobody filled in.
  102. sub _pinned ($query) {
  103. return defined $query->{version} && $query->{version} ne '';
  104. }
  105. sub _build_query ($query) {
  106. my $select = q{SELECT p.post_data, p.uuid, p.version, v.version_max, v.first_created, f.author AS first_author};
  107. my ( $where, $bind ) = _where($query) or return;
  108. # A raw read with no filters has no clauses at all, and "WHERE" on its own
  109. # is a syntax error rather than "everything".
  110. push( @$where, '1 = 1' ) unless @$where;
  111. my $sql = "$select " . _from_clause() . " WHERE " . join( ' AND ', @$where );
  112. # Newest first, by the same date the caller gets back as created -- the
  113. # post's birthday. The flat file model returned posts in readdir order,
  114. # which is to say whatever order the filesystem felt like; every caller that
  115. # cared went through the tag index, which ordered by created DESC.
  116. $sql .= ' ORDER BY v.first_created DESC, p.uuid DESC';
  117. # 0 means unlimited, as it does in the flat file model.
  118. my @extra;
  119. my $limit = defined $query->{limit} ? int( $query->{limit} ) : 25;
  120. if ($limit) {
  121. $sql .= ' LIMIT ?';
  122. push( @extra, $limit );
  123. if ( $query->{page} ) {
  124. $sql .= ' OFFSET ?';
  125. push( @extra, ( int( $query->{page} ) - 1 ) * $limit );
  126. }
  127. }
  128. return ( $sql, @$bind, @extra );
  129. }
  130. # filter(), clause for clause. Read the two side by side when changing either.
  131. # Returns nothing when no post can match, and then there is no query to run.
  132. sub _where ($query) {
  133. my ( @where, @bind );
  134. # Which versions are in play. Everything else filters within that.
  135. #
  136. # raw is the exception: it means "the history, as stored", which is what a
  137. # migration or an index rebuild reads, so it pins nothing.
  138. if ( _pinned($query) ) {
  139. push( @where, 'p.version = ?' );
  140. push( @bind, int( $query->{version} ) );
  141. }
  142. elsif ( !$query->{raw} ) {
  143. push( @where, 'p.id = v.latest_id' );
  144. }
  145. # An id, title or aclname query is answered on its own, with no tag or date
  146. # filtering after it, but still only if the caller may see it -- exactly as
  147. # filter() does for these three.
  148. foreach my $key (qw{id title aclname}) {
  149. next unless $query->{$key};
  150. my $column = $key eq 'id' ? 'p.uuid' : "p.$key";
  151. push( @where, "$column = ?" );
  152. push( @bind, $query->{$key} );
  153. return unless _acl_clause( $query, \@where, \@bind );
  154. return ( \@where, \@bind );
  155. }
  156. # Against the first version's date, not this row's. filter() applies these
  157. # after _dedup_versions() has already rewritten created to the oldest
  158. # version's, so older/newer have always meant the post's birthday rather
  159. # than the date of its most recent edit.
  160. if ( $query->{older} ) {
  161. ( my $older = $query->{older} ) =~ s/[^0-9]//g;
  162. push( @where, 'v.first_created < ?' );
  163. push( @bind, $older );
  164. }
  165. if ( $query->{newer} ) {
  166. ( my $newer = $query->{newer} ) =~ s/[^0-9]//g;
  167. push( @where, 'v.first_created > ?' );
  168. push( @bind, $newer );
  169. }
  170. # Any one of these tags. post_tags is one row per tag per post version, so
  171. # membership is an indexed lookup rather than a scan of every post's array.
  172. if ( ref $query->{tags} eq 'ARRAY' && @{ $query->{tags} } ) {
  173. my $binds = join( ',', map { '?' } @{ $query->{tags} } );
  174. push( @where, "p.id IN (SELECT post_id FROM post_tags WHERE tag IN ($binds))" );
  175. push( @bind, @{ $query->{tags} } );
  176. }
  177. # None of these.
  178. if ( ref $query->{exclude_tags} eq 'ARRAY' && @{ $query->{exclude_tags} } ) {
  179. my $binds = join( ',', map { '?' } @{ $query->{exclude_tags} } );
  180. push( @where, "p.id NOT IN (SELECT post_id FROM post_tags WHERE tag IN ($binds))" );
  181. push( @bind, @{ $query->{exclude_tags} } );
  182. }
  183. return unless _acl_clause( $query, \@where, \@bind );
  184. if ( $query->{form} ) {
  185. push( @where, 'COALESCE(p.form, ?) = ?' );
  186. push( @bind, '', $query->{form} );
  187. }
  188. if ( $query->{author} ) {
  189. push( @where, 'p.user = ?' );
  190. push( @bind, $query->{author} );
  191. }
  192. if ( length( $query->{like} // '' ) ) {
  193. my ( $clause, @like_bind ) = _like_clause( $query->{like} );
  194. push( @where, $clause );
  195. push( @bind, @like_bind );
  196. }
  197. return ( \@where, \@bind );
  198. }
  199. # A post is only visible to a caller holding one of its tags, since visibility
  200. # and acls are both stored as tags. admin skips it, as it does in filter().
  201. # False when the caller can see nothing at all, so there is nothing to query.
  202. sub _acl_clause ( $query, $where, $bind ) {
  203. # raw skips filter() altogether in the flat file model, acls included.
  204. # Only bin/migrate.pl and an index rebuild ask for it.
  205. return 1 if $query->{raw};
  206. my $acls = ref $query->{acls} eq 'ARRAY' ? $query->{acls} : [];
  207. return 0 unless @$acls;
  208. return 1 if any { $_ eq 'admin' } @$acls;
  209. my $binds = join( ',', map { '?' } @$acls );
  210. push( @$where, "p.id IN (SELECT post_id FROM post_tags WHERE tag IN ($binds))" );
  211. push( @$bind, @$acls );
  212. return 1;
  213. }
  214. =head2 _like_clause($term)
  215. The 'like' filter: a case insensitive substring match over a post's title and
  216. its content.
  217. Answered by the FTS5 index, which is built with the trigram tokenizer precisely
  218. so that substring is a thing it can answer. The term goes in as a quoted
  219. phrase, doubling any quote in it, so that a search for C<AND> or C<"> is a
  220. search for that text rather than FTS5 query syntax the user did not ask for.
  221. Trigram indexes nothing shorter than three characters, so shorter terms fall
  222. back to a LIKE scan. That is the slow path, and it is the only one -- but a one
  223. or two character search matches most of the site anyway.
  224. =cut
  225. sub _like_clause ($term) {
  226. if ( length($term) < 3 ) {
  227. my $escaped = $term;
  228. $escaped =~ s/([\\%_])/\\$1/g;
  229. return ( "(p.title LIKE ? ESCAPE '\\' OR p.data LIKE ? ESCAPE '\\')", "%$escaped%", "%$escaped%" );
  230. }
  231. my $phrase = $term;
  232. $phrase =~ s/"/""/g;
  233. return ( 'p.id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH ?)', qq{"$phrase"} );
  234. }
  235. =head1 WRITING
  236. =head2 write($posts)
  237. Insert each post as a new row.
  238. No read-modify-write, and so nothing to lose: the flat file model had to take a
  239. lock around rewriting the whole version history of a post, because the history
  240. was the file. Here a version is a row, and the unique index on (uuid, version)
  241. is what rejects two workers writing the same version rather than one of them
  242. silently winning.
  243. Several posts go in as one transaction, so a batch either lands or it doesn't --
  244. add() hands us everything it was given at once, and half a save is not a state
  245. anybody asked for. It is also the difference between a migration taking seconds
  246. and taking minutes, since SQLite otherwise commits once per row.
  247. =cut
  248. sub write ( $self, $data ) {
  249. my $dbh = _dbh();
  250. Trog::SQLite::transaction( $dbh, sub { _insert( $dbh, $data ) } );
  251. return 1;
  252. }
  253. sub _insert ( $dbh, $data ) {
  254. my $insert = $dbh->prepare('INSERT INTO posts (post_data) VALUES (?)');
  255. foreach my $post (@$data) {
  256. confess "Cannot write a post with no id" unless $post->{id};
  257. # Not stored: they are computed per query from the version history, and
  258. # writing them would bake one query's answer into the post forever.
  259. my %stored = %$post;
  260. delete @stored{qw{version_max modified display_name user_class post_id}};
  261. $stored{version} //= 0;
  262. # Caught to name the post: a rejected write -- the unique index catching
  263. # two workers on the same version, most of all -- otherwise dies with
  264. # the message of DBI alone, which does not say which post it was.
  265. eval { $insert->execute( $parser->encode( \%stored ) ); 1 }
  266. or confess "Could not write post '$post->{id}' version $stored{version}: $@";
  267. }
  268. return 1;
  269. }
  270. =head2 versions_present() = HASHREF
  271. Every (uuid, version) already stored, as C<< { uuid => { version => 1 } } >>.
  272. For bulk importers, which would otherwise ask the database once per post
  273. whether it has seen it before. See bin/migrate.pl.
  274. =cut
  275. sub versions_present ($self) {
  276. my $rows = _dbh()->selectall_arrayref( 'SELECT uuid, version FROM posts', { Slice => {} } ) // [];
  277. my %present;
  278. $present{ $_->{uuid} }{ $_->{version} } = 1 foreach @$rows;
  279. return \%present;
  280. }
  281. =head2 delete(@posts)
  282. Remove every version of each post. The triggers in the schema take the tag rows
  283. and the search index with them.
  284. =cut
  285. sub delete ( $self, @posts ) {
  286. my $dbh = _dbh();
  287. my $delete = $dbh->prepare('DELETE FROM posts WHERE uuid = ?');
  288. $delete->execute( $_->{id} ) foreach @posts;
  289. return 0;
  290. }
  291. =head2 count() = INT
  292. How many posts there are, counting a post once however many versions it has.
  293. =cut
  294. sub count ($self) {
  295. my ($count) = _dbh()->selectrow_array('SELECT COUNT(DISTINCT uuid) FROM posts');
  296. return $count // 0;
  297. }
  298. =head2 index_fields(@fields)
  299. Give each named custom field a virtual column and an index, and take the indexes
  300. back off the fields no longer named.
  301. This is what the Post Type Wizard's per-field "Index this field" checkbox does
  302. when the site is on this data model. A virtual generated column costs no
  303. storage and ADD COLUMN on one rewrites nothing, so the expensive half is
  304. building the index, which is a one-off.
  305. Called with every indexed field across every post type, not just the type that
  306. was saved -- two types can declare the same field name, and reconciling against
  307. the whole picture is what stops saving one of them dropping the other's index.
  308. Only indexes this created are ever dropped, which is why they carry a prefix.
  309. The columns are left in place: they cost nothing, something may reference one,
  310. and re-ticking the box then only has to rebuild the index.
  311. Field names are re-validated here rather than trusted from the caller -- they
  312. reach SQLite as an identifier and a JSON path, neither of which can be bound as
  313. a parameter.
  314. =cut
  315. our $index_prefix = 'posts_custom_';
  316. # The columns schema/sqlite.schema defines for itself. The wizard already
  317. # refuses a custom field named after anything in the post schema, which covers
  318. # most of these; this is the backstop for the ones that are not post fields at
  319. # all, and it is what stops "index this field" quietly indexing the blob.
  320. our %reserved = map { $_ => 1 } qw{id post_data uuid version created title user author form aclname visibility local_href data};
  321. sub index_fields ( $self, @fields ) {
  322. my $dbh = _dbh();
  323. my %want;
  324. foreach my $field (@fields) {
  325. my ($safe) = ( $field // '' ) =~ m/^([a-z][a-z0-9_]{0,31})$/;
  326. if ( !defined $safe ) {
  327. WARN( "Refusing to index '" . ( $field // '' ) . "': not a field name" );
  328. next;
  329. }
  330. if ( $reserved{$safe} ) {
  331. WARN("Refusing to index '$safe': the schema owns that column, and already indexes the ones worth indexing");
  332. next;
  333. }
  334. $want{$safe} = 1;
  335. }
  336. # Whatever is already there, base columns included. A custom field can
  337. # never be named after one of those -- the wizard refuses any name already
  338. # in the post schema -- so anything found here is either ours or was added
  339. # by hand, and either way there is nothing to add.
  340. #
  341. # xinfo rather than info: table_info omits generated columns, which is every
  342. # column on this table bar the blob, so it would report an empty slate every
  343. # time and ADD COLUMN would fail on the second call for the same field.
  344. my %have = map { $_->{name} => 1 } @{ $dbh->selectall_arrayref( 'PRAGMA table_xinfo(posts)', { Slice => {} } ) // [] };
  345. my $built = 0;
  346. foreach my $field ( sort keys %want ) {
  347. if ( !$have{$field} ) {
  348. ## no critic(ValuesAndExpressions::PreventSQLInjection) -- $field is the anchored capture above
  349. eval { $dbh->do("ALTER TABLE posts ADD COLUMN $field TEXT GENERATED ALWAYS AS (json_extract(post_data, '\$.$field')) VIRTUAL"); 1 }
  350. or confess "Could not add a column for '$field': $@";
  351. }
  352. ## no critic(ValuesAndExpressions::PreventSQLInjection) -- as above
  353. eval { $dbh->do("CREATE INDEX IF NOT EXISTS $index_prefix$field ON posts($field)"); 1 }
  354. or confess "Could not index '$field': $@";
  355. $built++;
  356. }
  357. # Anything we built before and were not asked for this time.
  358. my $ours = $dbh->selectall_arrayref(
  359. "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'posts' AND name LIKE ?",
  360. { Slice => {} },
  361. "$index_prefix%"
  362. ) // [];
  363. my $dropped = 0;
  364. foreach my $index ( map { $_->{name} } @$ours ) {
  365. my $field = substr( $index, length($index_prefix) );
  366. next if $want{$field};
  367. ## no critic(ValuesAndExpressions::PreventSQLInjection) -- $index came out of sqlite_master
  368. eval { $dbh->do("DROP INDEX IF EXISTS $index"); 1 } or confess "Could not drop the index on '$field': $@";
  369. $dropped++;
  370. }
  371. INFO("Indexed $built custom field(s), dropped $dropped") if $built || $dropped;
  372. return $built;
  373. }
  374. =head2 tags() = @tags
  375. Every tag in use, from the index the schema's triggers maintain.
  376. =cut
  377. sub tags ($self) {
  378. my $rows = _dbh()->selectall_arrayref( 'SELECT DISTINCT tag FROM post_tags ORDER BY tag', { Slice => {} } );
  379. return () unless ref $rows eq 'ARRAY';
  380. return map { $_->{tag} } @$rows;
  381. }
  382. 1;