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
data/Rakefile
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'bundler/gem_tasks'
|
|
4
|
-
require '
|
|
4
|
+
require 'rake/testtask'
|
|
5
5
|
|
|
6
|
-
|
|
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 '
|
|
14
|
+
require_relative 'test/dummy/config/application'
|
|
10
15
|
Dummy::Application.load_tasks
|
|
11
16
|
end
|
|
12
17
|
|
|
13
|
-
task default: :
|
|
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'
|
data/benchmark/README.md
ADDED
|
@@ -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.
|