SQLite.pm 16 KB

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