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.
- checksums.yaml +4 -4
- data/.github/workflows/lint_code.yml +9 -6
- data/.github/workflows/lint_docs.yml +15 -16
- data/.github/workflows/release.yml +23 -0
- data/.github/workflows/tests.yml +97 -0
- data/.gitignore +16 -7
- data/.rubocop.yml +8 -16
- data/.ruby-version +1 -1
- data/Gemfile +1 -1
- data/README.md +273 -236
- data/Rakefile +9 -4
- data/assets/metka-icon.svg +23 -0
- data/assets/metka-logo.svg +28 -0
- data/benchmark/Gemfile +17 -0
- data/benchmark/README.md +170 -0
- data/benchmark/benchmark.rb +718 -0
- data/benchmark/results.sqlite.txt +131 -0
- data/benchmark/results.txt +150 -0
- data/bin/setup +7 -0
- data/docs/superpowers/plans/2026-08-18-cloud-table-naming.md +316 -0
- data/docs/superpowers/specs/2026-08-18-cloud-table-naming-design.md +105 -0
- data/forspell.dict +3 -1
- data/gemfiles/rails71.gemfile +6 -0
- data/gemfiles/rails72.gemfile +6 -0
- data/gemfiles/rails80.gemfile +6 -0
- data/gemfiles/rails81.gemfile +6 -0
- data/gemfiles/rubocop.gemfile +2 -2
- data/lib/generators/metka/strategies/index/index_generator.rb +77 -0
- data/lib/generators/metka/strategies/index/templates/migration.rb.erb +89 -0
- data/lib/generators/metka/strategies/table/table_generator.rb +83 -0
- data/lib/generators/metka/strategies/table/templates/migration.rb.erb +128 -0
- data/lib/generators/metka/strategies/table/templates/migration.sqlite.rb.erb +93 -0
- data/lib/metka/generic_parser.rb +28 -14
- data/lib/metka/model.rb +121 -51
- data/lib/metka/query_builder.rb +45 -49
- data/lib/metka/tag_list.rb +16 -8
- data/lib/metka/tags_query.rb +93 -0
- data/lib/metka/version.rb +1 -1
- data/lib/metka.rb +24 -10
- data/metka.gemspec +21 -17
- metadata +43 -104
- data/.github/workflows/specs.yml +0 -86
- data/.rspec +0 -2
- data/Gemfile.lock +0 -239
- data/gemfiles/rails52.gemfile +0 -6
- data/gemfiles/rails6.gemfile +0 -6
- data/gemfiles/rails61.gemfile +0 -6
- data/lib/generators/metka/strategies/materialized_view/materialized_view_generator.rb +0 -73
- data/lib/generators/metka/strategies/materialized_view/templates/migration.rb.erb +0 -54
- data/lib/generators/metka/strategies/view/templates/migration.rb.erb +0 -26
- data/lib/generators/metka/strategies/view/view_generator.rb +0 -70
- data/lib/metka/query_builder/all_tags_query.rb +0 -11
- data/lib/metka/query_builder/any_tags_query.rb +0 -11
- 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
data/gemfiles/rubocop.gemfile
CHANGED
|
@@ -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
|