TagIndex.pm 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. package Trog::SQLite::TagIndex;
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use URI::Escape;
  6. use List::Util qw{uniq};
  7. use Trog::SQLite;
  8. =head1 Trog::SQLite::TagIndex
  9. An SQLite3 index of posts by tag and date.
  10. Used to speed up the flat-file data model.
  11. Also used to retrieve cached routes from posts.
  12. =head1 FUNCTIONS
  13. =cut
  14. sub posts_for_tags (@tags) {
  15. my $dbh = dbh();
  16. my $clause = @tags ? "WHERE tag IN (" . join( ',', ( map { '?' } @tags ) ) . ")" : '';
  17. ## no critic(ValuesAndExpressions::PreventSQLInjection)
  18. my $rows = $dbh->selectall_arrayref( "SELECT DISTINCT id FROM posts $clause ORDER BY created DESC", { Slice => {} }, @tags );
  19. return () unless ref $rows eq 'ARRAY' && @$rows;
  20. return map { $_->{id} } @$rows;
  21. }
  22. sub routes {
  23. my $dbh = dbh();
  24. my $rows = $dbh->selectall_arrayref( "SELECT id, route, method, callback FROM all_routes", { Slice => {} } );
  25. return () unless ref $rows eq 'ARRAY' && @$rows;
  26. #XXX not sure how this gets escaped going in.
  27. my %routes = map { URI::Escape::uri_unescape( $_->{route} ) => $_ } @$rows;
  28. return %routes;
  29. }
  30. sub aliases {
  31. my $dbh = dbh();
  32. my $rows = $dbh->selectall_arrayref( "SELECT actual,alias FROM aliases", { Slice => {} } );
  33. return () unless ref $rows eq 'ARRAY' && @$rows;
  34. my %aliases = map { $_->{alias} => $_->{actual} } @$rows;
  35. return %aliases;
  36. }
  37. sub tags {
  38. my $dbh = dbh();
  39. my $rows = $dbh->selectall_arrayref( "SELECT name FROM tag", { Slice => {} } );
  40. return () unless ref $rows eq 'ARRAY' && @$rows;
  41. return map { $_->{name} } @$rows;
  42. }
  43. sub add_post ( $post, $data_obj ) {
  44. my $dbh = dbh();
  45. Trog::SQLite::transaction(
  46. $dbh,
  47. sub {
  48. # posts_index has no key for INSERT OR IGNORE to skip a duplicate on,
  49. # so a save would add every tag again and keep the ones it removed.
  50. $dbh->do( "DELETE FROM posts_index WHERE post_id IN (SELECT id FROM post WHERE uuid=?)", undef, $post->{id} );
  51. build_index( $data_obj, [$post] );
  52. build_routes( $data_obj, [$post] );
  53. }
  54. );
  55. return 1;
  56. }
  57. =head2 rebuild($data_obj)
  58. Empty the index, the route table and the aliases, then fill them again from
  59. every post $data_obj holds. It is one transaction, so a reader sees the old
  60. index or the new one, and never an empty one.
  61. The HTTP methods are kept, as the schema fills those rather than any post.
  62. =cut
  63. sub rebuild ($data_obj) {
  64. my $dbh = dbh();
  65. Trog::SQLite::transaction(
  66. $dbh,
  67. sub {
  68. # Cascades to posts_index, routes and post_aliases.
  69. $dbh->do("DELETE FROM post");
  70. $dbh->do("DELETE FROM tag");
  71. $dbh->do("DELETE FROM callbacks");
  72. build_index($data_obj);
  73. build_routes($data_obj);
  74. }
  75. );
  76. return 1;
  77. }
  78. sub remove_post ($post) {
  79. my $dbh = dbh();
  80. # Deleting the post will cascade to the post index & primary route, which cascades to the aliases
  81. $dbh->do( "DELETE FROM post WHERE uuid=?", undef, $post->{id} );
  82. # Now that we've wasted the routes and post, let's reap any dangling tags or callbacks.
  83. # We won't ever reap methods, because they're just HTTP methods in an enum table.
  84. $dbh->do("DELETE from callbacks WHERE id NOT IN (SELECT DISTINCT callback_id FROM routes)");
  85. $dbh->do("DELETE from tag WHERE id NOT IN (SELECT DISTINCT tag_id FROM posts_index)");
  86. return 1;
  87. }
  88. my sub id_for_uuid ( $dbh, @uuids ) {
  89. my $bind = join( ',', ( map { '?' } @uuids ) );
  90. Trog::SQLite::bulk_insert( $dbh, 'post', ['uuid'], 'IGNORE', @uuids );
  91. ## no critic(ValuesAndExpressions::PreventSQLInjection)
  92. return $dbh->selectall_hashref( "SELECT id,uuid FROM post WHERE uuid IN ($bind)", 'uuid', {}, @uuids );
  93. }
  94. sub build_index ( $data_obj, $posts = [] ) {
  95. my $dbh = dbh();
  96. $posts = $data_obj->read( { limit => 0, acls => ['admin'] } ) unless @$posts;
  97. # First, slap in the UUIDs
  98. my @uuids = map { $_->{id} } @$posts;
  99. Trog::SQLite::bulk_insert( $dbh, 'post', ['uuid'], 'IGNORE', @uuids );
  100. my $pids = id_for_uuid( $dbh, @uuids );
  101. foreach my $post (@$posts) {
  102. $post->{post_id} = $pids->{ $post->{id} }{id};
  103. }
  104. # Slap in the tags, plus the aclname in the event this is a series
  105. my @tags = uniq map { @{ $_->{tags} }, $_->{aclname} } @$posts;
  106. Trog::SQLite::bulk_insert( $dbh, 'tag', ['name'], 'IGNORE', @tags );
  107. #TODO restrict query to only the specific tags we care about
  108. my $t = $dbh->selectall_hashref( "SELECT id,name FROM tag", 'name' );
  109. foreach my $k ( keys(%$t) ) { $t->{$k} = $t->{$k}->{id} }
  110. # Finally, index the posts
  111. Trog::SQLite::bulk_insert(
  112. $dbh,
  113. 'posts_index',
  114. [qw{post_id post_time tag_id}],
  115. 'IGNORE',
  116. map {
  117. my $subj = $_;
  118. map { ( $subj->{post_id}, $subj->{created}, $t->{$_} ) } @{ $subj->{tags} }
  119. } @$posts
  120. );
  121. return 1;
  122. }
  123. # It is important we use get() instead of read() because of incomplete data.
  124. sub build_routes ( $data_obj, $posts = [] ) {
  125. my $dbh = dbh();
  126. @$posts = $data_obj->get( limit => 0, acls => ['admin'] ) unless @$posts;
  127. my @uuids = map { $_->{id} } @$posts;
  128. my $pids = id_for_uuid( $dbh, @uuids );
  129. foreach my $post (@$posts) {
  130. $post->{post_id} = $pids->{ $post->{id} }{id};
  131. }
  132. # Ensure the callbacks we need are installed
  133. Trog::SQLite::bulk_insert( $dbh, 'callbacks', [qw{callback}], 'IGNORE', ( uniq map { $_->{callback} } @$posts ) );
  134. my $m = $dbh->selectall_hashref( "SELECT id, method FROM methods", 'method' );
  135. foreach my $k ( keys(%$m) ) { $m->{$k} = $m->{$k}->{id} }
  136. my $c = $dbh->selectall_hashref( "SELECT id, callback FROM callbacks", 'callback' );
  137. foreach my $k ( keys(%$c) ) { $c->{$k} = $c->{$k}->{id} }
  138. @$posts = map {
  139. $_->{method_id} = $m->{ $_->{method} };
  140. $_->{callback_id} = $c->{ $_->{callback} };
  141. $_
  142. } @$posts;
  143. my @routes = map { ( $_->{post_id}, $_->{local_href}, $_->{method_id}, $_->{callback_id} ) } @$posts;
  144. Trog::SQLite::bulk_insert( $dbh, 'routes', [qw{post_id route method_id callback_id}], 'IGNORE', @routes );
  145. # Now, compile the post aliases
  146. my %routes_actual = routes();
  147. foreach my $post (@$posts) {
  148. next unless ( ref $post->{aliases} eq 'ARRAY' ) && @{ $post->{aliases} };
  149. my $route = $post->{local_href};
  150. Trog::SQLite::bulk_insert( $dbh, 'post_aliases', [qw{route_id alias}], 'IGNORE', map { ( $routes_actual{$route}{id}, $_ ) } @{ $post->{aliases} } );
  151. }
  152. return 1;
  153. }
  154. =head2 dbh() = DBI::db
  155. The handle to the tag index, data/posts.db, with its schema applied.
  156. Trog::SQLite caches it, so every call gets the same one.
  157. =cut
  158. sub dbh {
  159. my $file = 'schema/flatfile.schema';
  160. my $dbname = "data/posts.db";
  161. return Trog::SQLite::dbh( $file, $dbname );
  162. }
  163. 1;