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
data/Rakefile CHANGED
@@ -1,13 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/gem_tasks'
4
- require 'rspec/core/rake_task'
4
+ require 'rake/testtask'
5
5
 
6
- RSpec::Core::RakeTask.new(:spec)
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.pattern = 'test/**/*_test.rb'
9
+ t.warning = false
10
+ t.verbose = false
11
+ end
7
12
 
8
13
  namespace :dummy do
9
- require_relative 'spec/dummy/config/application'
14
+ require_relative 'test/dummy/config/application'
10
15
  Dummy::Application.load_tasks
11
16
  end
12
17
 
13
- task default: :spec
18
+ task default: :test
@@ -0,0 +1,23 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96">
2
+ <style>
3
+ .ink { fill: none; stroke: #24292f; }
4
+ @media (prefers-color-scheme: dark) {
5
+ .ink { stroke: #e6edf3; }
6
+ }
7
+ </style>
8
+ <!-- array brackets -->
9
+ <path class="ink" d="M32 16 H18 V80 H32" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
10
+ <path class="ink" d="M64 16 H78 V80 H64" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
11
+ <!-- back tag -->
12
+ <g transform="translate(48 48) scale(0.88) translate(-48 -48)">
13
+ <g transform="translate(27 30) rotate(-18)">
14
+ <path fill="#fb7185" fill-rule="evenodd" d="M12 0 H34 Q40 0 40 6 V20 Q40 26 34 26 H12 L2 15.6 Q0 13 2 10.4 Z
15
+ M12 13 m-3.2 0 a3.2 3.2 0 1 0 6.4 0 a3.2 3.2 0 1 0 -6.4 0"/>
16
+ </g>
17
+ <!-- front tag -->
18
+ <g transform="translate(23 48) rotate(-18)">
19
+ <path fill="#e11d48" fill-rule="evenodd" d="M12 0 H34 Q40 0 40 6 V20 Q40 26 34 26 H12 L2 15.6 Q0 13 2 10.4 Z
20
+ M12 13 m-3.2 0 a3.2 3.2 0 1 0 6.4 0 a3.2 3.2 0 1 0 -6.4 0"/>
21
+ </g>
22
+ </g>
23
+ </svg>
@@ -0,0 +1,28 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 336 96" width="336" height="96">
2
+ <style>
3
+ .ink { fill: none; stroke: #24292f; }
4
+ .word { fill: #24292f; }
5
+ @media (prefers-color-scheme: dark) {
6
+ .ink { stroke: #e6edf3; }
7
+ .word { fill: #e6edf3; }
8
+ }
9
+ </style>
10
+ <!-- array brackets -->
11
+ <path class="ink" d="M32 16 H18 V80 H32" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
12
+ <path class="ink" d="M64 16 H78 V80 H64" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
13
+ <!-- back tag -->
14
+ <g transform="translate(48 48) scale(0.88) translate(-48 -48)">
15
+ <g transform="translate(27 30) rotate(-18)">
16
+ <path fill="#fb7185" fill-rule="evenodd" d="M12 0 H34 Q40 0 40 6 V20 Q40 26 34 26 H12 L2 15.6 Q0 13 2 10.4 Z
17
+ M12 13 m-3.2 0 a3.2 3.2 0 1 0 6.4 0 a3.2 3.2 0 1 0 -6.4 0"/>
18
+ </g>
19
+ <!-- front tag -->
20
+ <g transform="translate(23 48) rotate(-18)">
21
+ <path fill="#e11d48" fill-rule="evenodd" d="M12 0 H34 Q40 0 40 6 V20 Q40 26 34 26 H12 L2 15.6 Q0 13 2 10.4 Z
22
+ M12 13 m-3.2 0 a3.2 3.2 0 1 0 6.4 0 a3.2 3.2 0 1 0 -6.4 0"/>
23
+ </g>
24
+ </g>
25
+ <!-- wordmark -->
26
+ <text class="word" x="104" y="63" font-family="'Inter','SF Pro Display','Segoe UI','Helvetica Neue',Arial,sans-serif"
27
+ font-size="46" font-weight="700" letter-spacing="-1">Metka</text>
28
+ </svg>
data/benchmark/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rails', '~> 8.0'
6
+ gem 'pg'
7
+ gem 'sqlite3'
8
+
9
+ gem 'metka', path: '..'
10
+
11
+ gem 'acts-as-taggable-on'
12
+ gem 'acts-as-taggable-array-on'
13
+ gem 'gutentag'
14
+ gem 'tag_columns'
15
+
16
+ gem 'benchmark'
17
+ gem 'benchmark-ips'
@@ -0,0 +1,170 @@
1
+ # Metka benchmark
2
+
3
+ Compares Metka against four other ActiveRecord tagging gems:
4
+
5
+ | Gem | Version | Storage model | Runs on |
6
+ | --- | --- | --- | --- |
7
+ | [metka](https://github.com/metka-ruby/metka) | this checkout | PostgreSQL array column + GIN index; JSON column on SQLite | PostgreSQL, SQLite |
8
+ | [acts-as-taggable-array-on](https://github.com/tmiyamon/acts-as-taggable-array-on) | 0.7.0 | PostgreSQL array column + GIN index | PostgreSQL |
9
+ | [tag_columns](https://github.com/hopsoft/tag_columns) | 0.1.10 | PostgreSQL array column + GIN index | PostgreSQL |
10
+ | [acts-as-taggable-on](https://github.com/mbleigh/acts-as-taggable-on) | 13.0.0 | normalized `tags` + polymorphic `taggings` join tables | PostgreSQL, SQLite |
11
+ | [gutentag](https://github.com/pat/gutentag) | 3.0.1 | normalized `gutentag_tags` + `gutentag_taggings` join tables | PostgreSQL, SQLite |
12
+
13
+ ## Running
14
+
15
+ The PostgreSQL run needs a scratch server (the run drops and recreates all
16
+ tables):
17
+
18
+ ```bash
19
+ docker run -d --name metka-bench-pg \
20
+ -e POSTGRES_PASSWORD=bench -e POSTGRES_USER=bench -e POSTGRES_DB=metka_bench \
21
+ -p 127.0.0.1:5434:5432 postgres:18
22
+ bundle install
23
+ bundle exec ruby benchmark.rb # POSTS=n to change dataset size
24
+ ```
25
+
26
+ The SQLite run needs nothing external — it benchmarks against a local
27
+ `benchmark.sqlite3` file, recreated on every run:
28
+
29
+ ```bash
30
+ DB=sqlite bundle exec ruby benchmark.rb
31
+ ```
32
+
33
+ Dataset: 10,000 posts per gem, 5 tags per post drawn from a 100-tag
34
+ vocabulary, identical tag assignment across gems (seeded RNG). Join-table
35
+ schemas come from each gem's own bundled migrations. Raw output of the last
36
+ runs is in `results.txt` (PostgreSQL) and `results.sqlite.txt` (SQLite).
37
+
38
+ Metka is benchmarked twice in the cloud/write suites, once per tag-cloud
39
+ aggregate: `table` (statement-level triggers upsert per-tag deltas into a
40
+ summary table; this is what the main results table reports) and bare (no
41
+ aggregate maintained, tag clouds computed on the fly). The table DDL matches
42
+ the output of the `metka:strategies:table` generator.
43
+
44
+ ## Results (Ruby 4.0.6, Rails 8.1, PostgreSQL 18.3, 10k posts)
45
+
46
+ Higher i/s is better; multipliers are relative to the fastest gem per row.
47
+ The metka column has the `metka:strategies:table` aggregate in place. Tag
48
+ queries never touch the aggregate, so the two query rows are measured on the
49
+ bare table and apply to either setup.
50
+
51
+ | Operation | metka | taggable-array | tag_columns | acts-as-taggable-on | gutentag |
52
+ | --- | --- | --- | --- | --- | --- |
53
+ | Query: ALL of 2 tags, load records | 6,003 i/s | 6,725 i/s | 986 (6.8x slower) | 2,292 (2.9x slower) | 1,299 (5.2x slower) |
54
+ | Query: ANY of 2 tags, count | 4,575 i/s | 4,479 i/s | 678 (6.7x slower) | 788 (5.8x slower) | 1,027 (4.5x slower) |
55
+ | Tag cloud (counts over 10k posts) | 9,025 i/s | 204 (44x slower) | 198 (46x slower) | 127 (71x slower) | 161 (56x slower) |
56
+ | Create post with 5 tags | 1,495 i/s | 1,634 i/s | 1,599 i/s | 204 (8.0x slower) | 196 (8.3x slower) |
57
+ | Replace tags of existing post | 8,131 i/s | 7,766 i/s | 7,396 i/s | 190 (43x slower) | 183 (44x slower) |
58
+ | Bulk seed 10k posts (`insert_all` where possible) | 0.17 s | 0.17 s | 0.16 s | 45.6 s | 50.2 s |
59
+ | Storage, tables + indexes | 2.75 MB | 2.68 MB | 2.68 MB | 18.17 MB | 10.97 MB |
60
+
61
+ On queries and writes, differences between metka and
62
+ acts-as-taggable-array-on are within benchmark noise; on the tag cloud, the
63
+ maintained summary table puts metka ~44x ahead of every gem that aggregates
64
+ on the fly.
65
+
66
+ Bare metka — no aggregate maintained, tag clouds computed on the fly with
67
+ `UNNEST .. GROUP BY` — on the same dataset (`table` strategy repeated for
68
+ reference):
69
+
70
+ | Operation | metka (table) | metka (bare) |
71
+ | --- | --- | --- |
72
+ | Tag cloud (counts over 10k posts) | 9,025 i/s | 212 (43x slower) |
73
+ | Create post with 5 tags | 1,495 i/s | 1,619 i/s |
74
+ | Replace tags of existing post | 8,131 i/s | 8,293 i/s |
75
+ | Bulk seed 10k posts | 0.17 s | 0.21 s |
76
+ | Storage, tables + indexes | 2.75 MB | 2.68 MB |
77
+
78
+ After all suites (tens of thousands of trigger firings), the maintained
79
+ aggregate matched a live `UNNEST .. GROUP BY` aggregation exactly — the
80
+ script verifies this at the end of every run.
81
+
82
+ ## Results on SQLite (Ruby 4.0.6, Rails 8.1, SQLite 3.53, 10k posts)
83
+
84
+ `DB=sqlite` benchmarks the gems that run on SQLite: metka stores tags in a
85
+ JSON column and queries through `json_each`, acts-as-taggable-on and gutentag
86
+ use their join tables unchanged. acts-as-taggable-array-on and tag_columns
87
+ are PostgreSQL-array-only and are skipped. Metka appears three ways: bare
88
+ (query rows are measured there), with the table aggregate maintaining the
89
+ tag cloud, and with the `metka:strategies:index` side table answering
90
+ queries via index seeks:
91
+
92
+ | Operation | metka | metka (index) | acts-as-taggable-on | gutentag |
93
+ | --- | --- | --- | --- | --- |
94
+ | Query: ALL of 2 tags, load records | 398 (18x slower) | 7,199 i/s | 3,189 (2.3x slower) | 1,965 (3.7x slower) |
95
+ | Query: ANY of 2 tags, count | 379 (11x slower) | 4,113 i/s | 410 (10x slower) | 1,900 (2.2x slower) |
96
+ | Tag cloud (counts over 10k posts) | 12,697 i/s (table) | — | 111 (115x slower) | 88 (145x slower) |
97
+ | Create post with 5 tags | 6,895 i/s (table) | 5,349 (1.3x slower) | 268 (26x slower) | 284 (24x slower) |
98
+ | Replace tags of existing post | 12,604 i/s (table) | 12,696 i/s | 416 (30x slower) | 809 (16x slower) |
99
+ | Bulk seed 10k posts (`insert_all` where possible) | 0.08 s | 0.10 s | 31.4 s | 35.3 s |
100
+ | Storage, tables + indexes | 0.63 MB | 1.39 MB | 12.53 MB | 7.20 MB |
101
+
102
+ What the strategies cost relative to bare metka on the same dataset:
103
+
104
+ | Operation | metka (bare) | metka (table) | metka (index) |
105
+ | --- | --- | --- | --- |
106
+ | Tag cloud (counts over 10k posts) | 111 i/s | 12,697 i/s | — |
107
+ | Query: ALL of 2 tags, load records | 398 i/s | 398 i/s | 7,199 i/s |
108
+ | Create post with 5 tags | 7,783 i/s | 6,895 i/s | 5,349 i/s |
109
+ | Replace tags of existing post | 12,670 i/s | 12,604 i/s | 12,696 i/s |
110
+ | Bulk seed 10k posts | 0.10 s | 0.08 s | 0.10 s |
111
+ | Storage, tables + indexes | 0.62 MB | 0.63 MB | 1.39 MB |
112
+
113
+ Both integrity checks hold on SQLite: after all suites the table strategy's
114
+ counters matched a live `json_each .. GROUP BY` aggregation exactly, and the
115
+ index strategy's pairs matched a live `DISTINCT (tag, id)` projection
116
+ exactly.
117
+
118
+ Reading the tables: by default the query rows flip in favor of the
119
+ join-table gems. SQLite has no GIN equivalent, so metka's `EXISTS json_each`
120
+ predicates scan the table (~2.5 ms per query at 10k rows, growing linearly),
121
+ while the join-table gems' ordinary B-tree indexes work on SQLite exactly as
122
+ they do on PostgreSQL. The index strategy takes the reads back: its
123
+ `WITHOUT ROWID` (tag_name, record_id) table turns "all" into an INTERSECT
124
+ of index seeks and "any" into one IN probe — 18x over the scan, 2.3x ahead
125
+ of acts-as-taggable-on — and its per-row triggers cost ~1.5x on create
126
+ (measured against bare metka; still ~20x ahead of the join-table gems),
127
+ nothing measurable on replace, and ~0.8 MB per 10k posts. Everything else
128
+ favors metka in every setup: single-column writes beat the
129
+ load-diff-insert tagging machinery ~16–30x, `insert_all` seeding works at
130
+ all (the join-table gems must create row by row), and even with the index
131
+ table metka is ~5–9x smaller on disk than tags + taggings + their indexes.
132
+ Writes are also where SQLite punishes the join-table gems hardest: every
133
+ tagging row insert/delete is a separate statement against a single-writer
134
+ database.
135
+
136
+ ## Why the numbers fall where they do
137
+
138
+ - **Array gems vs join-table gems.** Every tag operation in metka /
139
+ acts-as-taggable-array-on is a single-table statement
140
+ (`tags @> ARRAY[...]`, `tags && ARRAY[...]`) served by the GIN index.
141
+ acts-as-taggable-on builds one `INNER JOIN taggings` per requested tag plus
142
+ `ILIKE` subqueries against `tags`; gutentag uses an
143
+ `IN (SELECT ... GROUP BY ... HAVING COUNT(*))` subquery. Writes are the
144
+ starkest difference: replacing a tag list is a one-column `UPDATE` for the
145
+ array gems, while the join-table gems load current taggings, diff them, and
146
+ insert/delete rows plus counter-cache updates — hence the ~44x gap.
147
+ - **tag_columns defeats its own index.** Its scopes wrap the column in
148
+ `CAST(tags AS text[])`, and the planner will not use the GIN index on the
149
+ column under a cast: `EXPLAIN` shows metka using a Bitmap Index Scan and
150
+ tag_columns a Seq Scan for the same logical query. Writes (no cast
151
+ involved) match the other array gems.
152
+ - **What the table strategy costs.** It serves tag-cloud reads from a small
153
+ pre-aggregated relation (~43x faster than aggregating live) while keeping
154
+ every write within benchmark noise of bare metka: its statement-level
155
+ triggers read the statement's transition tables and upsert only the touched
156
+ tags' counters, so a write pays for the tags it changed rather than for a
157
+ full re-aggregation. It stays exact: the run ends by checking the aggregate
158
+ against a live aggregation.
159
+ - **What you give up with arrays.** acts-as-taggable-on and gutentag maintain
160
+ a normalized tag vocabulary, which array columns don't give you: global
161
+ rename in one place, tag metadata, taggings_count caches, cross-model tags,
162
+ taggers/contexts (ATO). Metka's answer for tag-count aggregates is the
163
+ `metka:strategies:table` generator. If those features are unused,
164
+ the join tables are pure overhead — 4.4–6.6x on disk here.
165
+
166
+ Caveats: single machine, Dockerized PostgreSQL with default settings, the
167
+ SQLite run against a local file database with default pragmas, one run per
168
+ suite via benchmark-ips (2 s warmup / 5 s measure), 10k rows fits in memory.
169
+ Relative ordering is stable across runs; treat absolute numbers as
170
+ indicative only.