TagIndex.pm 6.6 KB

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