metka 2.3.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/lint_code.yml +9 -6
  3. data/.github/workflows/lint_docs.yml +15 -16
  4. data/.github/workflows/release.yml +23 -0
  5. data/.github/workflows/tests.yml +97 -0
  6. data/.gitignore +16 -7
  7. data/.rubocop.yml +8 -16
  8. data/.ruby-version +1 -1
  9. data/Gemfile +1 -1
  10. data/README.md +273 -236
  11. data/Rakefile +9 -4
  12. data/assets/metka-icon.svg +23 -0
  13. data/assets/metka-logo.svg +28 -0
  14. data/benchmark/Gemfile +17 -0
  15. data/benchmark/README.md +170 -0
  16. data/benchmark/benchmark.rb +718 -0
  17. data/benchmark/results.sqlite.txt +131 -0
  18. data/benchmark/results.txt +150 -0
  19. data/bin/setup +7 -0
  20. data/docs/superpowers/plans/2026-08-18-cloud-table-naming.md +316 -0
  21. data/docs/superpowers/specs/2026-08-18-cloud-table-naming-design.md +105 -0
  22. data/forspell.dict +3 -1
  23. data/gemfiles/rails71.gemfile +6 -0
  24. data/gemfiles/rails72.gemfile +6 -0
  25. data/gemfiles/rails80.gemfile +6 -0
  26. data/gemfiles/rails81.gemfile +6 -0
  27. data/gemfiles/rubocop.gemfile +2 -2
  28. data/lib/generators/metka/strategies/index/index_generator.rb +77 -0
  29. data/lib/generators/metka/strategies/index/templates/migration.rb.erb +89 -0
  30. data/lib/generators/metka/strategies/table/table_generator.rb +83 -0
  31. data/lib/generators/metka/strategies/table/templates/migration.rb.erb +128 -0
  32. data/lib/generators/metka/strategies/table/templates/migration.sqlite.rb.erb +93 -0
  33. data/lib/metka/generic_parser.rb +28 -14
  34. data/lib/metka/model.rb +121 -51
  35. data/lib/metka/query_builder.rb +45 -49
  36. data/lib/metka/tag_list.rb +16 -8
  37. data/lib/metka/tags_query.rb +93 -0
  38. data/lib/metka/version.rb +1 -1
  39. data/lib/metka.rb +24 -10
  40. data/metka.gemspec +21 -17
  41. metadata +43 -104
  42. data/.github/workflows/specs.yml +0 -86
  43. data/.rspec +0 -2
  44. data/Gemfile.lock +0 -239
  45. data/gemfiles/rails52.gemfile +0 -6
  46. data/gemfiles/rails6.gemfile +0 -6
  47. data/gemfiles/rails61.gemfile +0 -6
  48. data/lib/generators/metka/strategies/materialized_view/materialized_view_generator.rb +0 -73
  49. data/lib/generators/metka/strategies/materialized_view/templates/migration.rb.erb +0 -54
  50. data/lib/generators/metka/strategies/view/templates/migration.rb.erb +0 -26
  51. data/lib/generators/metka/strategies/view/view_generator.rb +0 -70
  52. data/lib/metka/query_builder/all_tags_query.rb +0 -11
  53. data/lib/metka/query_builder/any_tags_query.rb +0 -11
  54. data/lib/metka/query_builder/base_query.rb +0 -48
@@ -0,0 +1,105 @@
1
+ # Table-Strategy Summary Table Naming: `<source_table>_<columns>_cloud`
2
+
3
+ **Date:** 2026-08-18
4
+ **Status:** Approved by user
5
+ **Target:** 3.0 release
6
+
7
+ ## Problem
8
+
9
+ The table strategy generator derives its summary table name as
10
+ `tagged_<source_table>` (e.g. `tagged_songs`), or
11
+ `tagged_with_<columns>_<source_table>` when non-default source columns are
12
+ given (e.g. `tagged_with_tags_and_genres_posts`). The prefix style reads
13
+ poorly, sorts the table away from its source table in the schema, and is
14
+ inconsistent with the index strategy, which names its tables
15
+ `<source_table>_<column>_index` (e.g. `posts_tags_index`).
16
+
17
+ The summary table is not a taggings join table: it holds one row per tag
18
+ (`tag_name varchar PRIMARY KEY, taggings_count bigint`) — a tag-cloud
19
+ aggregate maintained by triggers. The name should describe that.
20
+
21
+ ## Decision
22
+
23
+ Rename the derived default to `<source_table>_<columns>_cloud`, with columns
24
+ joined by `_and_`, uniformly — no special case for the default `tags` column:
25
+
26
+ | Input | Old derived name | New derived name |
27
+ |---|---|---|
28
+ | `--source-table-name=songs` | `tagged_songs` | `songs_tags_cloud` |
29
+ | `--source-table-name=posts --source-columns=tags genres` | `tagged_with_tags_and_genres_posts` | `posts_tags_and_genres_cloud` |
30
+
31
+ This mirrors the index strategy's `<source_table>_<column>_index` pattern, so
32
+ the two strategies' tables read as siblings in the schema
33
+ (`songs_tags_cloud`, `songs_tags_index`), and matches the feature's name
34
+ throughout the README and API (`tag_cloud`).
35
+
36
+ Alternatives considered and rejected:
37
+
38
+ - `song_taggings` (`<singular>_taggings`): Rails-idiomatic, but in the Rails
39
+ ecosystem "taggings" implies a join table with one row per tagging;
40
+ this table holds per-tag counts, so the name misleads schema readers.
41
+ - `song_tag_counts`: semantically precise but breaks symmetry with the index
42
+ strategy and derives awkwardly for non-default columns.
43
+
44
+ ## Changes
45
+
46
+ ### Generator (the only code change)
47
+
48
+ `lib/generators/metka/strategies/table/table_generator.rb` — `table_name`
49
+ becomes:
50
+
51
+ ```ruby
52
+ def table_name
53
+ return options[:table_name] if options[:table_name]
54
+
55
+ "#{source_table_name}_#{source_columns_names}_cloud"
56
+ end
57
+ ```
58
+
59
+ - The migration name keeps its existing `create_#{table_name}_table`
60
+ derivation (→ `create_songs_tags_cloud_table`).
61
+ - The `--table-name` option remains as the override, unchanged.
62
+
63
+ ### Templates: no changes
64
+
65
+ Both `migration.rb.erb` (PostgreSQL) and `migration.sqlite.rb.erb` (SQLite)
66
+ interpolate `<%= table_name %>` throughout — trigger function names become
67
+ `metka_ins_songs_tags_cloud()` etc. automatically. Trigger names derive from
68
+ the source table and columns, not the summary table, so they are untouched.
69
+
70
+ ### Docs and fixtures (model the new convention)
71
+
72
+ - `test/generators/strategies/table_generator_test.rb`: expected table and
73
+ migration names, including the trigger-function name assertions
74
+ (`metka_ins_tagged_notes` → `metka_ins_notes_tags_cloud`); trigger-name
75
+ assertions (`metka_ins_on_notes_...`) stay as they are.
76
+ - `README.md` table-strategy section: `tagged_notes` → `notes_tags_cloud`,
77
+ `TaggedNote` → `NotesTagsCloud`, and the derived-name explanation.
78
+ - Dummy app: `test/dummy/db/migrate/11_create_tagged_table_posts_table.rb`
79
+ and the `TaggedTablePost` model/test rename to the new convention
80
+ (summary table `table_posts_tags_cloud`).
81
+ - `benchmark/benchmark.rb`: table strategy table (`tagged_metka_table_posts`)
82
+ renamed to match. `benchmark/README.md` never names the table literally, so
83
+ it needs no change.
84
+
85
+ Pluralization note: Rails infers `notes_tags_clouds` (plural) from a model
86
+ named `NotesTagsCloud`, so the renamed dummy model and the README example
87
+ model must set `self.table_name = "notes_tags_cloud"` explicitly — the old
88
+ `TaggedNote` → `tagged_notes` inference worked by accident of the naming.
89
+
90
+ ## Compatibility
91
+
92
+ Generation-time only. The table name is baked into a migration when the user
93
+ runs the generator; no runtime code derives or reads the `tagged_` prefix.
94
+ Existing installs keep their `tagged_*` tables and working triggers untouched;
95
+ only newly generated migrations change. No deprecation shim is needed. The
96
+ README is the only doc surface (no CHANGELOG file in the repo).
97
+
98
+ ## Testing
99
+
100
+ - Updated generator tests assert the new derived names for the default and
101
+ multi-column cases, and that `--table-name` still overrides.
102
+ - Existing dummy-app trigger tests run against the renamed summary table,
103
+ proving the templates work unchanged with the new name.
104
+
105
+ No new test types are needed.
data/forspell.dict CHANGED
@@ -4,4 +4,6 @@
4
4
  # Example: deduplicate: duplicate
5
5
  Metka
6
6
  taggings
7
- benchmarked
7
+ benchmarked
8
+ gutentag
9
+ backfill: fill
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'rails', '~> 7.1.0'
4
+ gem 'activerecord', '~> 7.1.0'
5
+
6
+ gemspec path: '..'
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'rails', '~> 7.2.0'
4
+ gem 'activerecord', '~> 7.2.0'
5
+
6
+ gemspec path: '..'
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'rails', '~> 8.0.0'
4
+ gem 'activerecord', '~> 8.0.0'
5
+
6
+ gemspec path: '..'
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'rails', '~> 8.1.0'
4
+ gem 'activerecord', '~> 8.1.0'
5
+
6
+ gemspec path: '..'
@@ -1,4 +1,4 @@
1
1
  source "https://rubygems.org" do
2
2
  gem "rubocop-md", "~> 1.0"
3
- gem "jetrockets-standard"
4
- end
3
+ gem "rubocop-rails-omakase", "~> 1.1"
4
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Metka
7
+ module Generators
8
+ module Strategies
9
+ class IndexGenerator < ::Rails::Generators::Base # :nodoc:
10
+ include Rails::Generators::Migration
11
+
12
+ DEFAULT_SOURCE_COLUMNS = [ "tags" ].freeze
13
+
14
+ desc <<~LONGDESC
15
+ Generates a migration implementing the SQLite index strategy for
16
+ Metka: one (tag_name, record_id) side table per tagged column,
17
+ maintained by triggers, so tag queries become index seeks instead
18
+ of json_each table scans. Declare the tables on the model with the
19
+ index_tables option to route queries through them.
20
+
21
+ PostgreSQL needs no index strategy — GIN indexes on the array
22
+ columns already serve tag queries — so the generator produces
23
+ nothing there.
24
+
25
+ > $ rails g metka:strategies:index \
26
+ --source-table-name=NAME_OF_TABLE_WITH_TAGS \
27
+ --source-columns=NAME_OF_TAGGED_COLUMN_1 NAME_OF_TAGGED_COLUMN_2
28
+ LONGDESC
29
+
30
+ source_root File.expand_path("templates", __dir__)
31
+
32
+ class_option :source_table_name, type: :string, required: true,
33
+ desc: "Name of the table that has a column with tags"
34
+
35
+ class_option :source_columns, type: :array, default: DEFAULT_SOURCE_COLUMNS,
36
+ desc: "List of the tagged columns names"
37
+
38
+ def generate_migration
39
+ unless sqlite?
40
+ say_status :skipped,
41
+ "the index strategy targets SQLite; PostgreSQL GIN indexes already serve tag queries",
42
+ :yellow
43
+ return
44
+ end
45
+
46
+ migration_template "migration.rb.erb", "db/migrate/#{migration_name}.rb"
47
+ end
48
+
49
+ no_tasks do
50
+ def sqlite?
51
+ ::ActiveRecord::Base.connection.adapter_name.match?(/sqlite/i)
52
+ end
53
+
54
+ def source_table_name
55
+ options[:source_table_name]
56
+ end
57
+
58
+ def source_columns
59
+ options[:source_columns]
60
+ end
61
+
62
+ def index_table_for(column)
63
+ "#{source_table_name}_#{column}_index"
64
+ end
65
+
66
+ def migration_name
67
+ "create_#{source_table_name}_#{source_columns.join('_and_')}_index_tables"
68
+ end
69
+ end
70
+
71
+ def self.next_migration_number(dir)
72
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
4
+ # One (tag_name, record_id) side table per tagged column, so SQLite tag
5
+ # queries become index seeks instead of json_each table scans. The WITHOUT
6
+ # ROWID primary key (tag_name, record_id) is itself the covering index —
7
+ # no separate index needed — and doubles as the dedup guard: a tag
8
+ # duplicated inside one row's array stores a single pair, which is all
9
+ # membership queries need.
10
+ #
11
+ # Per-row triggers keep the pairs in step with every INSERT, UPDATE and
12
+ # DELETE, multi-row statements included. Deletes seek through the primary
13
+ # key (tag_name from OLD's array, then record_id), so no extra index on
14
+ # record_id is required. Writes that bypass the triggers (restoring from a
15
+ # dump) require reseeding the table by hand.
16
+ #
17
+ # Declare the tables on the model to route queries through them:
18
+ #
19
+ # include Metka::Model(
20
+ # columns: %w[<%= source_columns.join(' ') %>],
21
+ # index_tables: {
22
+ <% source_columns.each do |column| -%>
23
+ # "<%= column %>" => "<%= index_table_for(column) %>",
24
+ <% end -%>
25
+ # }
26
+ # )
27
+ def up
28
+ <% source_columns.each do |column| -%>
29
+ execute <<-SQL
30
+ CREATE TABLE <%= index_table_for(column) %> (
31
+ tag_name varchar NOT NULL,
32
+ record_id bigint NOT NULL,
33
+ PRIMARY KEY (tag_name, record_id)
34
+ ) WITHOUT ROWID;
35
+ SQL
36
+
37
+ execute <<-SQL
38
+ INSERT OR IGNORE INTO <%= index_table_for(column) %> (tag_name, record_id)
39
+ SELECT value, <%= source_table_name %>.id
40
+ FROM <%= source_table_name %>, json_each(<%= source_table_name %>.<%= column %>);
41
+ SQL
42
+
43
+ execute <<-SQL
44
+ CREATE TRIGGER metka_idx_ins_on_<%= source_table_name %>_<%= column %>
45
+ AFTER INSERT ON <%= source_table_name %>
46
+ FOR EACH ROW
47
+ BEGIN
48
+ INSERT OR IGNORE INTO <%= index_table_for(column) %> (tag_name, record_id)
49
+ SELECT value, NEW.id FROM json_each(NEW.<%= column %>);
50
+ END;
51
+ SQL
52
+
53
+ execute <<-SQL
54
+ CREATE TRIGGER metka_idx_upd_on_<%= source_table_name %>_<%= column %>
55
+ AFTER UPDATE OF <%= column %> ON <%= source_table_name %>
56
+ FOR EACH ROW
57
+ BEGIN
58
+ DELETE FROM <%= index_table_for(column) %>
59
+ WHERE tag_name IN (SELECT value FROM json_each(OLD.<%= column %>))
60
+ AND record_id = OLD.id;
61
+
62
+ INSERT OR IGNORE INTO <%= index_table_for(column) %> (tag_name, record_id)
63
+ SELECT value, NEW.id FROM json_each(NEW.<%= column %>);
64
+ END;
65
+ SQL
66
+
67
+ execute <<-SQL
68
+ CREATE TRIGGER metka_idx_del_on_<%= source_table_name %>_<%= column %>
69
+ AFTER DELETE ON <%= source_table_name %>
70
+ FOR EACH ROW
71
+ BEGIN
72
+ DELETE FROM <%= index_table_for(column) %>
73
+ WHERE tag_name IN (SELECT value FROM json_each(OLD.<%= column %>))
74
+ AND record_id = OLD.id;
75
+ END;
76
+ SQL
77
+
78
+ <% end -%>
79
+ end
80
+
81
+ def down
82
+ <% source_columns.each do |column| -%>
83
+ execute "DROP TRIGGER IF EXISTS metka_idx_ins_on_<%= source_table_name %>_<%= column %>;"
84
+ execute "DROP TRIGGER IF EXISTS metka_idx_upd_on_<%= source_table_name %>_<%= column %>;"
85
+ execute "DROP TRIGGER IF EXISTS metka_idx_del_on_<%= source_table_name %>_<%= column %>;"
86
+ execute "DROP TABLE IF EXISTS <%= index_table_for(column) %>;"
87
+ <% end -%>
88
+ end
89
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Metka
7
+ module Generators
8
+ module Strategies
9
+ class TableGenerator < ::Rails::Generators::Base # :nodoc:
10
+ include Rails::Generators::Migration
11
+
12
+ DEFAULT_SOURCE_COLUMNS = [ "tags" ].freeze
13
+
14
+ desc <<~LONGDESC
15
+ Generates migration to implement table strategy for Metka
16
+
17
+ > $ rails g metka:strategies:table \
18
+ --source-table-name=NAME_OF_TABLE_WITH_TAGS \
19
+ --source-columns=NAME_OF_TAGGED_COLUMN_1 NAME_OF_TAGGED_COLUMN_2 \
20
+ --table-name=NAME_OF_TABLE
21
+ LONGDESC
22
+
23
+ source_root File.expand_path("templates", __dir__)
24
+
25
+ class_option :source_table_name, type: :string, required: true,
26
+ desc: "Name of the table that has a column with tags"
27
+
28
+ class_option :source_columns, type: :array, default: DEFAULT_SOURCE_COLUMNS,
29
+ desc: "List of the tagged columns names"
30
+
31
+ class_option :table_name, type: :string,
32
+ desc: "Custom name for the resulting table"
33
+
34
+ def generate_migration
35
+ migration_template migration_template_file, "db/migrate/#{migration_name}.rb"
36
+ end
37
+
38
+ no_tasks do
39
+ # The migration is written for the database the app is connected to
40
+ # at generation time: transition-table triggers for PostgreSQL,
41
+ # per-row json_each triggers for SQLite.
42
+ def migration_template_file
43
+ if ::ActiveRecord::Base.connection.adapter_name.match?(/sqlite/i)
44
+ "migration.sqlite.rb.erb"
45
+ else
46
+ "migration.rb.erb"
47
+ end
48
+ end
49
+
50
+ def source_table_name
51
+ options[:source_table_name]
52
+ end
53
+
54
+ def source_columns
55
+ options[:source_columns]
56
+ end
57
+
58
+ def source_columns_names
59
+ source_columns.join("_and_")
60
+ end
61
+
62
+ def table_name
63
+ return options[:table_name] if options[:table_name]
64
+
65
+ "#{source_table_name}_#{source_columns_names}_cloud"
66
+ end
67
+
68
+ def migration_name
69
+ "create_#{table_name}_table"
70
+ end
71
+
72
+ def migration_class_name
73
+ migration_name.classify
74
+ end
75
+ end
76
+
77
+ def self.next_migration_number(dir)
78
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
4
+ def up
5
+ execute <<-SQL
6
+ -- A real table maintained by per-tag deltas: statement-level triggers read
7
+ -- the transition tables and upsert only the tags the statement touched, so
8
+ -- maintenance cost is O(tags touched) instead of a full recompute. Writes
9
+ -- that bypass these triggers (TRUNCATE, restoring from a dump) require
10
+ -- reseeding the table by hand.
11
+
12
+ -- Block writes (reads stay unblocked) until the triggers exist: a write
13
+ -- committing between the seed's snapshot and CREATE TRIGGER would be seen
14
+ -- by neither and drift the counts. CREATE TRIGGER takes this same lock
15
+ -- level anyway; this only takes it before the seed instead of after.
16
+ LOCK TABLE <%= source_table_name %> IN SHARE ROW EXCLUSIVE MODE;
17
+
18
+ CREATE TABLE <%= table_name %> (
19
+ tag_name varchar PRIMARY KEY,
20
+ taggings_count bigint NOT NULL
21
+ );
22
+
23
+ INSERT INTO <%= table_name %> (tag_name, taggings_count)
24
+ SELECT
25
+ tag_name,
26
+ COUNT(*) AS taggings_count
27
+ FROM (
28
+ SELECT UNNEST
29
+ (<%= source_columns.join(' || ') %>) AS tag_name
30
+ FROM
31
+ <%= source_table_name %>
32
+ ) subquery
33
+ GROUP BY
34
+ tag_name;
35
+
36
+ -- UNNEST of a NULL array yields no rows and array concatenation treats a
37
+ -- NULL operand as empty, so NULL tagged columns need no explicit guards.
38
+ -- One function per operation: a transition table is only registered for
39
+ -- its own trigger, so the other operations' functions would fail to parse.
40
+ CREATE OR REPLACE FUNCTION metka_ins_<%= table_name %>() RETURNS trigger LANGUAGE plpgsql AS $$
41
+ BEGIN
42
+ INSERT INTO <%= table_name %> (tag_name, taggings_count)
43
+ SELECT tag_name, COUNT(*)
44
+ FROM (
45
+ SELECT UNNEST (<%= source_columns.join(' || ') %>) AS tag_name
46
+ FROM new_rows
47
+ ) subquery
48
+ GROUP BY tag_name
49
+ ON CONFLICT (tag_name)
50
+ DO UPDATE SET taggings_count = <%= table_name %>.taggings_count + EXCLUDED.taggings_count;
51
+ RETURN NULL;
52
+ END $$;
53
+
54
+ CREATE OR REPLACE FUNCTION metka_upd_<%= table_name %>() RETURNS trigger LANGUAGE plpgsql AS $$
55
+ BEGIN
56
+ WITH deltas AS (
57
+ SELECT tag_name, SUM(d) AS delta
58
+ FROM (
59
+ SELECT UNNEST (<%= source_columns.join(' || ') %>) AS tag_name, 1 AS d FROM new_rows
60
+ UNION ALL
61
+ SELECT UNNEST (<%= source_columns.join(' || ') %>) AS tag_name, -1 AS d FROM old_rows
62
+ ) changes
63
+ GROUP BY tag_name
64
+ HAVING SUM(d) <> 0
65
+ )
66
+ INSERT INTO <%= table_name %> (tag_name, taggings_count)
67
+ SELECT tag_name, delta FROM deltas
68
+ ON CONFLICT (tag_name)
69
+ DO UPDATE SET taggings_count = <%= table_name %>.taggings_count + EXCLUDED.taggings_count;
70
+
71
+ DELETE FROM <%= table_name %> WHERE taggings_count <= 0;
72
+ RETURN NULL;
73
+ END $$;
74
+
75
+ CREATE OR REPLACE FUNCTION metka_del_<%= table_name %>() RETURNS trigger LANGUAGE plpgsql AS $$
76
+ BEGIN
77
+ UPDATE <%= table_name %>
78
+ SET taggings_count = <%= table_name %>.taggings_count - removed.taggings_count
79
+ FROM (
80
+ SELECT tag_name, COUNT(*) AS taggings_count
81
+ FROM (
82
+ SELECT UNNEST (<%= source_columns.join(' || ') %>) AS tag_name
83
+ FROM old_rows
84
+ ) subquery
85
+ GROUP BY tag_name
86
+ ) removed
87
+ WHERE <%= table_name %>.tag_name = removed.tag_name;
88
+
89
+ DELETE FROM <%= table_name %> WHERE taggings_count <= 0;
90
+ RETURN NULL;
91
+ END $$;
92
+
93
+ -- The ins/upd/del discriminator sits at the front of the trigger names so
94
+ -- they stay distinct even when PostgreSQL truncates identifiers to 63
95
+ -- characters. One trigger per operation: a trigger with transition tables
96
+ -- must be declared for exactly one event.
97
+ CREATE TRIGGER metka_ins_on_<%= source_table_name %>_<%= source_columns_names %>
98
+ AFTER INSERT ON <%= source_table_name %>
99
+ REFERENCING NEW TABLE AS new_rows
100
+ FOR EACH STATEMENT
101
+ EXECUTE PROCEDURE metka_ins_<%= table_name %>();
102
+
103
+ CREATE TRIGGER metka_upd_on_<%= source_table_name %>_<%= source_columns_names %>
104
+ AFTER UPDATE ON <%= source_table_name %>
105
+ REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
106
+ FOR EACH STATEMENT
107
+ EXECUTE PROCEDURE metka_upd_<%= table_name %>();
108
+
109
+ CREATE TRIGGER metka_del_on_<%= source_table_name %>_<%= source_columns_names %>
110
+ AFTER DELETE ON <%= source_table_name %>
111
+ REFERENCING OLD TABLE AS old_rows
112
+ FOR EACH STATEMENT
113
+ EXECUTE PROCEDURE metka_del_<%= table_name %>();
114
+ SQL
115
+ end
116
+
117
+ def down
118
+ execute <<-SQL
119
+ DROP TRIGGER IF EXISTS metka_ins_on_<%= source_table_name %>_<%= source_columns_names %> ON <%= source_table_name %>;
120
+ DROP TRIGGER IF EXISTS metka_upd_on_<%= source_table_name %>_<%= source_columns_names %> ON <%= source_table_name %>;
121
+ DROP TRIGGER IF EXISTS metka_del_on_<%= source_table_name %>_<%= source_columns_names %> ON <%= source_table_name %>;
122
+ DROP FUNCTION IF EXISTS metka_ins_<%= table_name %>;
123
+ DROP FUNCTION IF EXISTS metka_upd_<%= table_name %>;
124
+ DROP FUNCTION IF EXISTS metka_del_<%= table_name %>;
125
+ DROP TABLE IF EXISTS <%= table_name %>;
126
+ SQL
127
+ end
128
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
4
+ # A real table maintained by per-tag deltas. SQLite has no statement-level
5
+ # triggers or transition tables, so per-row triggers read NEW/OLD
6
+ # through json_each and apply the deltas one row at a time; multi-row
7
+ # statements (insert_all, update_all, delete_all) still keep exact counts
8
+ # because the trigger fires once per affected row. SQLite allows one writer
9
+ # per database and DDL is transactional, so nothing can write between the
10
+ # seed and CREATE TRIGGER and no explicit lock is needed. Writes that bypass
11
+ # the triggers (restoring from a dump) require reseeding the table by hand.
12
+ #
13
+ # Requires SQLite >= 3.35 for ON CONFLICT inside a trigger body. The
14
+ # WHERE true in the upsert disambiguates ON CONFLICT from a join clause in
15
+ # the INSERT ... SELECT form — a documented SQLite parser requirement.
16
+ # json_each(NULL) yields no rows, so NULL tagged columns need no guards, and
17
+ # a tag duplicated inside one row's array upserts once per occurrence —
18
+ # counted exactly as PostgreSQL's UNNEST-based triggers count it.
19
+ def up
20
+ execute <<-SQL
21
+ CREATE TABLE <%= table_name %> (
22
+ tag_name varchar PRIMARY KEY,
23
+ taggings_count bigint NOT NULL
24
+ );
25
+ SQL
26
+
27
+ execute <<-SQL
28
+ INSERT INTO <%= table_name %> (tag_name, taggings_count)
29
+ SELECT tag_name, COUNT(*)
30
+ FROM (
31
+ <%= source_columns.map { |column| "SELECT value AS tag_name FROM #{source_table_name}, json_each(#{source_table_name}.#{column})" }.join("\n UNION ALL\n ") %>
32
+ )
33
+ GROUP BY tag_name;
34
+ SQL
35
+
36
+ execute <<-SQL
37
+ CREATE TRIGGER metka_ins_on_<%= source_table_name %>_<%= source_columns_names %>
38
+ AFTER INSERT ON <%= source_table_name %>
39
+ FOR EACH ROW
40
+ BEGIN
41
+ <% source_columns.each do |column| -%>
42
+ INSERT INTO <%= table_name %> (tag_name, taggings_count)
43
+ SELECT value, 1 FROM json_each(NEW.<%= column %>) WHERE true
44
+ ON CONFLICT (tag_name)
45
+ DO UPDATE SET taggings_count = taggings_count + 1;
46
+ <% end -%>
47
+ END;
48
+ SQL
49
+
50
+ execute <<-SQL
51
+ CREATE TRIGGER metka_upd_on_<%= source_table_name %>_<%= source_columns_names %>
52
+ AFTER UPDATE OF <%= source_columns.join(", ") %> ON <%= source_table_name %>
53
+ FOR EACH ROW
54
+ BEGIN
55
+ <% source_columns.each do |column| -%>
56
+ INSERT INTO <%= table_name %> (tag_name, taggings_count)
57
+ SELECT value, 1 FROM json_each(NEW.<%= column %>) WHERE true
58
+ ON CONFLICT (tag_name)
59
+ DO UPDATE SET taggings_count = taggings_count + 1;
60
+ <% end -%>
61
+ <% source_columns.each do |column| -%>
62
+ UPDATE <%= table_name %>
63
+ SET taggings_count = taggings_count -
64
+ (SELECT COUNT(*) FROM json_each(OLD.<%= column %>) WHERE value = tag_name)
65
+ WHERE tag_name IN (SELECT value FROM json_each(OLD.<%= column %>));
66
+ <% end -%>
67
+ DELETE FROM <%= table_name %> WHERE taggings_count <= 0;
68
+ END;
69
+ SQL
70
+
71
+ execute <<-SQL
72
+ CREATE TRIGGER metka_del_on_<%= source_table_name %>_<%= source_columns_names %>
73
+ AFTER DELETE ON <%= source_table_name %>
74
+ FOR EACH ROW
75
+ BEGIN
76
+ <% source_columns.each do |column| -%>
77
+ UPDATE <%= table_name %>
78
+ SET taggings_count = taggings_count -
79
+ (SELECT COUNT(*) FROM json_each(OLD.<%= column %>) WHERE value = tag_name)
80
+ WHERE tag_name IN (SELECT value FROM json_each(OLD.<%= column %>));
81
+ <% end -%>
82
+ DELETE FROM <%= table_name %> WHERE taggings_count <= 0;
83
+ END;
84
+ SQL
85
+ end
86
+
87
+ def down
88
+ execute "DROP TRIGGER IF EXISTS metka_ins_on_<%= source_table_name %>_<%= source_columns_names %>;"
89
+ execute "DROP TRIGGER IF EXISTS metka_upd_on_<%= source_table_name %>_<%= source_columns_names %>;"
90
+ execute "DROP TRIGGER IF EXISTS metka_del_on_<%= source_table_name %>_<%= source_columns_names %>;"
91
+ execute "DROP TABLE IF EXISTS <%= table_name %>;"
92
+ end
93
+ end