metka 2.3.3 → 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 -16
  41. metadata +45 -92
  42. data/.github/workflows/specs.yml +0 -86
  43. data/.rspec +0 -2
  44. data/Gemfile.lock +0 -201
  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,718 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Benchmarks Metka against other ActiveRecord tagging gems:
4
+ #
5
+ # metka — PostgreSQL array column (this repo), JSON column on SQLite
6
+ # acts-as-taggable-array-on — PostgreSQL array column
7
+ # tag_columns — PostgreSQL array column
8
+ # acts-as-taggable-on — normalized tags/taggings join tables
9
+ # gutentag — normalized tags/taggings join tables
10
+ #
11
+ # Metka appears twice in the cloud/write suites, once per tag-cloud
12
+ # strategy: bare (no aggregate maintained) and table (statement triggers
13
+ # upsert per-tag deltas into a summary table). The strategy DDL matches the
14
+ # output of the metka:strategies:table generator.
15
+ #
16
+ # Usage:
17
+ # bundle exec ruby benchmark.rb
18
+ # DB=sqlite bundle exec ruby benchmark.rb
19
+ #
20
+ # The default run expects PostgreSQL reachable via the DB constant below (see
21
+ # docker command in README notes). DB=sqlite benchmarks against a local SQLite
22
+ # file instead; the PostgreSQL-array gems (acts-as-taggable-array-on,
23
+ # tag_columns) cannot run there and are skipped, and the metka trigger DDL is
24
+ # the per-row output of the generator's SQLite template. The whole database is
25
+ # dropped and re-seeded on every run.
26
+
27
+ $stdout.sync = true
28
+
29
+ require "bundler/setup"
30
+ require "active_record"
31
+ require "benchmark/ips"
32
+ require "benchmark"
33
+
34
+ SQLITE = %w[sqlite sqlite3].include?(ENV["DB"])
35
+
36
+ DB =
37
+ if SQLITE
38
+ {
39
+ adapter: "sqlite3",
40
+ database: File.expand_path("benchmark.sqlite3", __dir__),
41
+ pool: 5
42
+ }
43
+ else
44
+ {
45
+ adapter: "postgresql",
46
+ host: ENV.fetch("PGHOST", "127.0.0.1"),
47
+ port: ENV.fetch("PGPORT", 5434),
48
+ username: ENV.fetch("PGUSER", "bench"),
49
+ password: ENV.fetch("PGPASSWORD", "bench"),
50
+ database: ENV.fetch("PGDATABASE", "metka_bench"),
51
+ pool: 5
52
+ }
53
+ end.freeze
54
+
55
+ File.delete(DB[:database]) if SQLITE && File.exist?(DB[:database])
56
+
57
+ POSTS_PER_GEM = Integer(ENV.fetch("POSTS", 10_000))
58
+ TAGS_PER_POST = 5
59
+ VOCABULARY = (1..100).map { |i| "tag#{format('%03d', i)}" }.freeze
60
+
61
+ ActiveRecord::Base.establish_connection(**DB)
62
+ ActiveRecord::Base.logger = nil
63
+ ActiveRecord::Migration.verbose = false
64
+
65
+ require "metka"
66
+ require "acts-as-taggable-on"
67
+ require "gutentag"
68
+
69
+ # PostgreSQL-array gems; their scopes cannot run on SQLite.
70
+ unless SQLITE
71
+ require "acts-as-taggable-array-on"
72
+ require "tag_columns"
73
+ end
74
+
75
+ # Gutentag's models normally load through its Rails engine; require them
76
+ # directly since this script boots plain ActiveRecord.
77
+ gutentag_root = Gem.loaded_specs["gutentag"].full_gem_path
78
+ require File.join(gutentag_root, "app/models/gutentag/tag")
79
+ require File.join(gutentag_root, "app/models/gutentag/tagging")
80
+
81
+ # ------------------------------------------------------------------ schema ---
82
+
83
+ def run_bundled_migrations(gem_name)
84
+ dir = Pathname.new(Gem.loaded_specs[gem_name].full_gem_path).join("db/migrate")
85
+ Dir[dir.join("*.rb")].sort.each { |file| require file }
86
+ end
87
+
88
+ ActiveRecord::Schema.define do
89
+ drop_table :metka_posts, if_exists: true
90
+ drop_table :metka_table_posts, if_exists: true, force: :cascade
91
+ unless SQLITE
92
+ drop_table :array_posts, if_exists: true
93
+ drop_table :tag_columns_posts, if_exists: true
94
+ end
95
+ drop_table :ato_posts, if_exists: true
96
+ drop_table :gutentag_posts, if_exists: true
97
+ drop_table :taggings, if_exists: true
98
+ drop_table :tags, if_exists: true
99
+ drop_table :gutentag_taggings, if_exists: true
100
+ drop_table :gutentag_tags, if_exists: true
101
+
102
+ # On SQLite tags live in a JSON column; there is no index that can serve
103
+ # membership-in-array predicates, so none is created. The third table gets
104
+ # the index strategy: a trigger-maintained (tag_name, record_id) side table
105
+ # that turns tag queries into index seeks.
106
+ if SQLITE
107
+ create_table :metka_posts do |t|
108
+ t.string :title
109
+ t.json :tags
110
+ end
111
+
112
+ create_table :metka_table_posts do |t|
113
+ t.string :title
114
+ t.json :tags
115
+ end
116
+
117
+ drop_table :metka_index_posts, if_exists: true
118
+ create_table :metka_index_posts do |t|
119
+ t.string :title
120
+ t.json :tags
121
+ end
122
+ else
123
+ create_table :metka_posts do |t|
124
+ t.string :title
125
+ t.string :tags, array: true
126
+ end
127
+ add_index :metka_posts, :tags, using: :gin
128
+
129
+ create_table :metka_table_posts do |t|
130
+ t.string :title
131
+ t.string :tags, array: true
132
+ end
133
+ add_index :metka_table_posts, :tags, using: :gin
134
+
135
+ create_table :array_posts do |t|
136
+ t.string :title
137
+ t.string :tags, array: true, default: []
138
+ end
139
+ add_index :array_posts, :tags, using: :gin
140
+
141
+ create_table :tag_columns_posts do |t|
142
+ t.string :title
143
+ t.string :tags, array: true, default: []
144
+ end
145
+ add_index :tag_columns_posts, :tags, using: :gin
146
+ end
147
+
148
+ create_table :ato_posts do |t|
149
+ t.string :title
150
+ end
151
+
152
+ create_table :gutentag_posts do |t|
153
+ t.string :title
154
+ end
155
+ end
156
+
157
+ run_bundled_migrations("acts-as-taggable-on")
158
+ [ ActsAsTaggableOnMigration, AddMissingUniqueIndices, AddTaggingsCounterCacheToTags,
159
+ AddMissingTaggableIndex, ChangeCollationForTagNames, AddMissingIndexesOnTaggings,
160
+ AddTenantToTaggings ].each { |m| m.migrate(:up) }
161
+
162
+ run_bundled_migrations("gutentag")
163
+ [ GutentagTables, GutentagCacheCounter, NoNullCounters ].each { |m| m.migrate(:up) }
164
+
165
+ # Tag-cloud strategy for the extra Metka table, as generated by `rails g
166
+ # metka:strategies:table` (comments stripped, names bound to the bench table).
167
+ # The SQLite variant matches the generator's SQLite template: per-row triggers
168
+ # upserting per-tag deltas read from NEW/OLD via json_each, one execute per
169
+ # statement since the sqlite3 driver runs one statement per call.
170
+ if SQLITE
171
+ conn = ActiveRecord::Base.connection
172
+ conn.execute("DROP TABLE IF EXISTS metka_table_posts_tags_cloud")
173
+
174
+ conn.execute(<<~SQL)
175
+ CREATE TABLE metka_table_posts_tags_cloud (
176
+ tag_name varchar PRIMARY KEY,
177
+ taggings_count bigint NOT NULL
178
+ );
179
+ SQL
180
+
181
+ conn.execute(<<~SQL)
182
+ INSERT INTO metka_table_posts_tags_cloud (tag_name, taggings_count)
183
+ SELECT value, COUNT(*)
184
+ FROM metka_table_posts, json_each(metka_table_posts.tags)
185
+ GROUP BY value;
186
+ SQL
187
+
188
+ conn.execute(<<~SQL)
189
+ CREATE TRIGGER metka_ins_on_metka_table_posts_tags
190
+ AFTER INSERT ON metka_table_posts
191
+ FOR EACH ROW
192
+ BEGIN
193
+ INSERT INTO metka_table_posts_tags_cloud (tag_name, taggings_count)
194
+ SELECT value, 1 FROM json_each(NEW.tags) WHERE true
195
+ ON CONFLICT (tag_name)
196
+ DO UPDATE SET taggings_count = taggings_count + 1;
197
+ END;
198
+ SQL
199
+
200
+ conn.execute(<<~SQL)
201
+ CREATE TRIGGER metka_upd_on_metka_table_posts_tags
202
+ AFTER UPDATE OF tags ON metka_table_posts
203
+ FOR EACH ROW
204
+ BEGIN
205
+ INSERT INTO metka_table_posts_tags_cloud (tag_name, taggings_count)
206
+ SELECT value, 1 FROM json_each(NEW.tags) WHERE true
207
+ ON CONFLICT (tag_name)
208
+ DO UPDATE SET taggings_count = taggings_count + 1;
209
+
210
+ UPDATE metka_table_posts_tags_cloud
211
+ SET taggings_count = taggings_count -
212
+ (SELECT COUNT(*) FROM json_each(OLD.tags) WHERE value = tag_name)
213
+ WHERE tag_name IN (SELECT value FROM json_each(OLD.tags));
214
+
215
+ DELETE FROM metka_table_posts_tags_cloud WHERE taggings_count <= 0;
216
+ END;
217
+ SQL
218
+
219
+ conn.execute(<<~SQL)
220
+ CREATE TRIGGER metka_del_on_metka_table_posts_tags
221
+ AFTER DELETE ON metka_table_posts
222
+ FOR EACH ROW
223
+ BEGIN
224
+ UPDATE metka_table_posts_tags_cloud
225
+ SET taggings_count = taggings_count -
226
+ (SELECT COUNT(*) FROM json_each(OLD.tags) WHERE value = tag_name)
227
+ WHERE tag_name IN (SELECT value FROM json_each(OLD.tags));
228
+
229
+ DELETE FROM metka_table_posts_tags_cloud WHERE taggings_count <= 0;
230
+ END;
231
+ SQL
232
+
233
+ # Index strategy for the third table, as generated by
234
+ # `rails g metka:strategies:index` (comments stripped, names bound).
235
+ conn.execute("DROP TABLE IF EXISTS metka_index_posts_tags_index")
236
+
237
+ conn.execute(<<~SQL)
238
+ CREATE TABLE metka_index_posts_tags_index (
239
+ tag_name varchar NOT NULL,
240
+ record_id bigint NOT NULL,
241
+ PRIMARY KEY (tag_name, record_id)
242
+ ) WITHOUT ROWID;
243
+ SQL
244
+
245
+ conn.execute(<<~SQL)
246
+ INSERT OR IGNORE INTO metka_index_posts_tags_index (tag_name, record_id)
247
+ SELECT value, metka_index_posts.id
248
+ FROM metka_index_posts, json_each(metka_index_posts.tags);
249
+ SQL
250
+
251
+ conn.execute(<<~SQL)
252
+ CREATE TRIGGER metka_idx_ins_on_metka_index_posts_tags
253
+ AFTER INSERT ON metka_index_posts
254
+ FOR EACH ROW
255
+ BEGIN
256
+ INSERT OR IGNORE INTO metka_index_posts_tags_index (tag_name, record_id)
257
+ SELECT value, NEW.id FROM json_each(NEW.tags);
258
+ END;
259
+ SQL
260
+
261
+ conn.execute(<<~SQL)
262
+ CREATE TRIGGER metka_idx_upd_on_metka_index_posts_tags
263
+ AFTER UPDATE OF tags ON metka_index_posts
264
+ FOR EACH ROW
265
+ BEGIN
266
+ DELETE FROM metka_index_posts_tags_index
267
+ WHERE tag_name IN (SELECT value FROM json_each(OLD.tags))
268
+ AND record_id = OLD.id;
269
+
270
+ INSERT OR IGNORE INTO metka_index_posts_tags_index (tag_name, record_id)
271
+ SELECT value, NEW.id FROM json_each(NEW.tags);
272
+ END;
273
+ SQL
274
+
275
+ conn.execute(<<~SQL)
276
+ CREATE TRIGGER metka_idx_del_on_metka_index_posts_tags
277
+ AFTER DELETE ON metka_index_posts
278
+ FOR EACH ROW
279
+ BEGIN
280
+ DELETE FROM metka_index_posts_tags_index
281
+ WHERE tag_name IN (SELECT value FROM json_each(OLD.tags))
282
+ AND record_id = OLD.id;
283
+ END;
284
+ SQL
285
+ else
286
+ ActiveRecord::Base.connection.execute(<<~SQL)
287
+ DROP TABLE IF EXISTS metka_table_posts_tags_cloud;
288
+ CREATE TABLE metka_table_posts_tags_cloud (
289
+ tag_name varchar PRIMARY KEY,
290
+ taggings_count bigint NOT NULL
291
+ );
292
+
293
+ INSERT INTO metka_table_posts_tags_cloud (tag_name, taggings_count)
294
+ SELECT tag_name, COUNT(*) AS taggings_count
295
+ FROM (SELECT UNNEST(tags) AS tag_name FROM metka_table_posts) subquery
296
+ GROUP BY tag_name;
297
+
298
+ CREATE OR REPLACE FUNCTION metka_ins_metka_table_posts_tags_cloud() RETURNS trigger LANGUAGE plpgsql AS $$
299
+ BEGIN
300
+ INSERT INTO metka_table_posts_tags_cloud (tag_name, taggings_count)
301
+ SELECT tag_name, COUNT(*)
302
+ FROM (SELECT UNNEST(tags) AS tag_name FROM new_rows) subquery
303
+ GROUP BY tag_name
304
+ ON CONFLICT (tag_name)
305
+ DO UPDATE SET taggings_count = metka_table_posts_tags_cloud.taggings_count + EXCLUDED.taggings_count;
306
+ RETURN NULL;
307
+ END $$;
308
+
309
+ CREATE OR REPLACE FUNCTION metka_upd_metka_table_posts_tags_cloud() RETURNS trigger LANGUAGE plpgsql AS $$
310
+ BEGIN
311
+ WITH deltas AS (
312
+ SELECT tag_name, SUM(d) AS delta
313
+ FROM (
314
+ SELECT UNNEST(tags) AS tag_name, 1 AS d FROM new_rows
315
+ UNION ALL
316
+ SELECT UNNEST(tags) AS tag_name, -1 AS d FROM old_rows
317
+ ) changes
318
+ GROUP BY tag_name
319
+ HAVING SUM(d) <> 0
320
+ )
321
+ INSERT INTO metka_table_posts_tags_cloud (tag_name, taggings_count)
322
+ SELECT tag_name, delta FROM deltas
323
+ ON CONFLICT (tag_name)
324
+ DO UPDATE SET taggings_count = metka_table_posts_tags_cloud.taggings_count + EXCLUDED.taggings_count;
325
+
326
+ DELETE FROM metka_table_posts_tags_cloud WHERE taggings_count <= 0;
327
+ RETURN NULL;
328
+ END $$;
329
+
330
+ CREATE OR REPLACE FUNCTION metka_del_metka_table_posts_tags_cloud() RETURNS trigger LANGUAGE plpgsql AS $$
331
+ BEGIN
332
+ UPDATE metka_table_posts_tags_cloud
333
+ SET taggings_count = metka_table_posts_tags_cloud.taggings_count - removed.taggings_count
334
+ FROM (
335
+ SELECT tag_name, COUNT(*) AS taggings_count
336
+ FROM (SELECT UNNEST(tags) AS tag_name FROM old_rows) subquery
337
+ GROUP BY tag_name
338
+ ) removed
339
+ WHERE metka_table_posts_tags_cloud.tag_name = removed.tag_name;
340
+
341
+ DELETE FROM metka_table_posts_tags_cloud WHERE taggings_count <= 0;
342
+ RETURN NULL;
343
+ END $$;
344
+
345
+ CREATE TRIGGER metka_ins_on_metka_table_posts_tags
346
+ AFTER INSERT ON metka_table_posts
347
+ REFERENCING NEW TABLE AS new_rows
348
+ FOR EACH STATEMENT
349
+ EXECUTE PROCEDURE metka_ins_metka_table_posts_tags_cloud();
350
+
351
+ CREATE TRIGGER metka_upd_on_metka_table_posts_tags
352
+ AFTER UPDATE ON metka_table_posts
353
+ REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
354
+ FOR EACH STATEMENT
355
+ EXECUTE PROCEDURE metka_upd_metka_table_posts_tags_cloud();
356
+
357
+ CREATE TRIGGER metka_del_on_metka_table_posts_tags
358
+ AFTER DELETE ON metka_table_posts
359
+ REFERENCING OLD TABLE AS old_rows
360
+ FOR EACH STATEMENT
361
+ EXECUTE PROCEDURE metka_del_metka_table_posts_tags_cloud();
362
+ SQL
363
+ end
364
+
365
+ # ------------------------------------------------------------------ models ---
366
+
367
+ class MetkaPost < ActiveRecord::Base
368
+ self.table_name = "metka_posts"
369
+ include Metka::Model(column: "tags")
370
+ end
371
+
372
+ class MetkaTablePost < ActiveRecord::Base
373
+ self.table_name = "metka_table_posts"
374
+ include Metka::Model(column: "tags")
375
+ end
376
+
377
+ class MetkaTablePostsTagsCloud < ActiveRecord::Base
378
+ self.table_name = "metka_table_posts_tags_cloud"
379
+ end
380
+
381
+ if SQLITE
382
+ class MetkaIndexPost < ActiveRecord::Base
383
+ self.table_name = "metka_index_posts"
384
+ include Metka::Model(column: "tags", index_tables: { "tags" => "metka_index_posts_tags_index" })
385
+ end
386
+ end
387
+
388
+ unless SQLITE
389
+ class ArrayPost < ActiveRecord::Base
390
+ self.table_name = "array_posts"
391
+ taggable_array :tags
392
+ end
393
+
394
+ class TagColumnsPost < ActiveRecord::Base
395
+ self.table_name = "tag_columns_posts"
396
+ include TagColumns
397
+ tag_columns :tags
398
+ end
399
+ end
400
+
401
+ class AtoPost < ActiveRecord::Base
402
+ self.table_name = "ato_posts"
403
+ acts_as_taggable_on :tags
404
+ end
405
+
406
+ class GutentagPost < ActiveRecord::Base
407
+ self.table_name = "gutentag_posts"
408
+ Gutentag::ActiveRecord.call self
409
+ end
410
+
411
+ # ------------------------------------------------------------------- seeds ---
412
+
413
+ rng = Random.new(42)
414
+ TAG_SETS = Array.new(POSTS_PER_GEM) { VOCABULARY.sample(TAGS_PER_POST, random: rng) }
415
+
416
+ def seed_with_insert_all(klass)
417
+ TAG_SETS.each_slice(1_000).with_index do |slice, i|
418
+ base = i * 1_000
419
+ rows = slice.each_with_index.map do |tags, j|
420
+ { title: "Post #{base + j}", tags: tags }
421
+ end
422
+ klass.insert_all(rows)
423
+ end
424
+ end
425
+
426
+ def seed_with_create(klass, tags_writer)
427
+ TAG_SETS.each_slice(500).with_index do |slice, i|
428
+ base = i * 500
429
+ klass.transaction do
430
+ slice.each_with_index do |tags, j|
431
+ record = klass.new(title: "Post #{base + j}")
432
+ record.public_send(tags_writer, tags)
433
+ record.save!
434
+ end
435
+ end
436
+ end
437
+ end
438
+
439
+ puts "Seeding #{POSTS_PER_GEM} posts per gem, #{TAGS_PER_POST} tags each, " \
440
+ "vocabulary of #{VOCABULARY.size} tags\n\n"
441
+
442
+ seed_times = {}
443
+ seed_times["metka"] = Benchmark.realtime { seed_with_insert_all(MetkaPost) }
444
+ seed_times["metka (table)"] = Benchmark.realtime { seed_with_insert_all(MetkaTablePost) }
445
+ seed_times["metka (index)"] = Benchmark.realtime { seed_with_insert_all(MetkaIndexPost) } if SQLITE
446
+ unless SQLITE
447
+ seed_times["acts-as-taggable-array-on"] = Benchmark.realtime { seed_with_insert_all(ArrayPost) }
448
+ seed_times["tag_columns"] = Benchmark.realtime { seed_with_insert_all(TagColumnsPost) }
449
+ end
450
+ seed_times["acts-as-taggable-on"] = Benchmark.realtime { seed_with_create(AtoPost, :tag_list=) }
451
+ seed_times["gutentag"] = Benchmark.realtime { seed_with_create(GutentagPost, :tag_names=) }
452
+
453
+ puts "Bulk seed wall time (#{POSTS_PER_GEM} posts):"
454
+ seed_times.each { |name, t| puts format(" %-28s %8.2f s", name, t) }
455
+ puts
456
+
457
+ # ------------------------------------------------------------ storage size ---
458
+
459
+ # Per-relation bytes including indexes. On SQLite this reads the dbstat
460
+ # virtual table (pages actually used by each btree), summing the table and
461
+ # every index sqlite_master attributes to it.
462
+ def relation_size(*tables)
463
+ conn = ActiveRecord::Base.connection
464
+
465
+ tables.sum do |t|
466
+ if SQLITE
467
+ names = conn.select_values(<<~SQL)
468
+ SELECT name FROM sqlite_master
469
+ WHERE tbl_name = #{conn.quote(t)} AND type IN ('table', 'index')
470
+ SQL
471
+ names.sum { |n| conn.select_value("SELECT SUM(pgsize) FROM dbstat WHERE name = #{conn.quote(n)}").to_i }
472
+ else
473
+ conn.select_value("SELECT pg_total_relation_size(#{conn.quote(t)})").to_i
474
+ end
475
+ end
476
+ end
477
+
478
+ sizes = {
479
+ "metka" => relation_size("metka_posts"),
480
+ "metka (table)" => relation_size("metka_table_posts", "metka_table_posts_tags_cloud"),
481
+ "acts-as-taggable-on" => relation_size("ato_posts", "tags", "taggings"),
482
+ "gutentag" => relation_size("gutentag_posts", "gutentag_tags", "gutentag_taggings")
483
+ }
484
+ sizes["metka (index)"] = relation_size("metka_index_posts", "metka_index_posts_tags_index") if SQLITE
485
+ unless SQLITE
486
+ sizes["acts-as-taggable-array-on"] = relation_size("array_posts")
487
+ sizes["tag_columns"] = relation_size("tag_columns_posts")
488
+ end
489
+
490
+ puts "Storage (tables + indexes) for #{POSTS_PER_GEM} posts:"
491
+ sizes.each { |name, s| puts format(" %-28s %8.2f MB", name, s / 1024.0 / 1024.0) }
492
+ puts
493
+
494
+ ActiveRecord::Base.connection.execute(SQLITE ? "ANALYZE" : "VACUUM ANALYZE")
495
+
496
+ # ------------------------------------------------------------- query pairs ---
497
+
498
+ pair_rng = Random.new(7)
499
+ PAIRS = Array.new(100) { VOCABULARY.sample(2, random: pair_rng) }
500
+
501
+ def next_pair
502
+ @pair_index = (@pair_index || 0) + 1
503
+ PAIRS[@pair_index % PAIRS.size]
504
+ end
505
+
506
+ # -------------------------------------------------------------- benchmarks ---
507
+
508
+ # WRITE_ONLY=1 skips the read suites; used to rerun the write suites alone.
509
+ WRITE_ONLY = ENV["WRITE_ONLY"] == "1"
510
+
511
+ unless WRITE_ONLY
512
+ puts "=" * 72
513
+ puts "QUERY: tagged with ALL of 2 tags -> load records (~#{(POSTS_PER_GEM * (TAGS_PER_POST / 100.0)**2).round} rows)"
514
+ puts "=" * 72
515
+ Benchmark.ips do |x|
516
+ x.config(warmup: 2, time: 5)
517
+ x.report("metka") { MetkaPost.tagged_with(next_pair).to_a }
518
+ if SQLITE
519
+ x.report("metka (index)") { MetkaIndexPost.tagged_with(next_pair).to_a }
520
+ else
521
+ x.report("acts-as-taggable-array-on") { ArrayPost.with_all_tags(next_pair).to_a }
522
+ x.report("tag_columns") { TagColumnsPost.with_all_tags(*next_pair).to_a }
523
+ end
524
+ x.report("acts-as-taggable-on") { AtoPost.tagged_with(next_pair).to_a }
525
+ x.report("gutentag") { GutentagPost.tagged_with(names: next_pair, match: :all).to_a }
526
+ x.compare!
527
+ end
528
+
529
+ puts "=" * 72
530
+ puts "QUERY: tagged with ANY of 2 tags -> count"
531
+ puts "=" * 72
532
+ Benchmark.ips do |x|
533
+ x.config(warmup: 2, time: 5)
534
+ x.report("metka") { MetkaPost.tagged_with(next_pair, any: true).count }
535
+ if SQLITE
536
+ x.report("metka (index)") { MetkaIndexPost.tagged_with(next_pair, any: true).count }
537
+ else
538
+ x.report("acts-as-taggable-array-on") { ArrayPost.with_any_tags(next_pair).count }
539
+ x.report("tag_columns") { TagColumnsPost.with_any_tags(*next_pair).count }
540
+ end
541
+ # ATO's relation counts via COUNT("ato_posts".*); SQLite cannot parse
542
+ # table.* inside an aggregate, so count(:all) (plain COUNT(*)) is used
543
+ # there — same rows counted, the EXISTS filter dedups either way.
544
+ x.report("acts-as-taggable-on") do
545
+ relation = AtoPost.tagged_with(next_pair, any: true)
546
+ SQLITE ? relation.count(:all) : relation.count
547
+ end
548
+ x.report("gutentag") { GutentagPost.tagged_with(names: next_pair, match: :any).count }
549
+ x.compare!
550
+ end
551
+
552
+ puts "=" * 72
553
+ puts "TAG CLOUD: tag -> usage count across all #{POSTS_PER_GEM} posts"
554
+ puts "=" * 72
555
+ Benchmark.ips do |x|
556
+ x.config(warmup: 2, time: 5)
557
+ x.report("metka") { MetkaPost.tag_cloud }
558
+ x.report("metka (table)") { MetkaTablePostsTagsCloud.pluck(:tag_name, :taggings_count) }
559
+ unless SQLITE
560
+ x.report("acts-as-taggable-array-on") { ArrayPost.tags_cloud }
561
+ x.report("tag_columns") { TagColumnsPost.tags_cloud }
562
+ end
563
+ x.report("acts-as-taggable-on") { AtoPost.tag_counts_on(:tags).map { |t| [ t.name, t.count ] } }
564
+ x.report("gutentag") do
565
+ Gutentag::Tag.joins(:taggings)
566
+ .where(gutentag_taggings: { taggable_type: "GutentagPost" })
567
+ .group(:name).count
568
+ end
569
+ x.compare!
570
+ end
571
+ end
572
+
573
+ puts "=" * 72
574
+ puts "WRITE: create one post with #{TAGS_PER_POST} tags"
575
+ puts "=" * 72
576
+ create_rng = Random.new(1)
577
+ CREATE_SETS = Array.new(1_000) { VOCABULARY.sample(TAGS_PER_POST, random: create_rng) }
578
+
579
+ def next_create_set
580
+ @create_index = (@create_index || 0) + 1
581
+ CREATE_SETS[@create_index % CREATE_SETS.size]
582
+ end
583
+
584
+ Benchmark.ips do |x|
585
+ x.config(warmup: 2, time: 5)
586
+ x.report("metka") do
587
+ p = MetkaPost.new(title: "bench")
588
+ p.tag_list = next_create_set
589
+ p.save!
590
+ end
591
+ x.report("metka (table)") do
592
+ p = MetkaTablePost.new(title: "bench")
593
+ p.tag_list = next_create_set
594
+ p.save!
595
+ end
596
+ if SQLITE
597
+ x.report("metka (index)") do
598
+ p = MetkaIndexPost.new(title: "bench")
599
+ p.tag_list = next_create_set
600
+ p.save!
601
+ end
602
+ else
603
+ x.report("acts-as-taggable-array-on") { ArrayPost.create!(title: "bench", tags: next_create_set) }
604
+ x.report("tag_columns") { TagColumnsPost.create!(title: "bench", tags: next_create_set) }
605
+ end
606
+ x.report("acts-as-taggable-on") do
607
+ p = AtoPost.new(title: "bench")
608
+ p.tag_list = next_create_set
609
+ p.save!
610
+ end
611
+ x.report("gutentag") do
612
+ p = GutentagPost.new(title: "bench")
613
+ p.tag_names = next_create_set
614
+ p.save!
615
+ end
616
+ x.compare!
617
+ end
618
+
619
+ puts "=" * 72
620
+ puts "WRITE: replace the tag list of an existing post"
621
+ puts "=" * 72
622
+ update_ids = {
623
+ metka: MetkaPost.limit(1_000).pluck(:id),
624
+ metka_table: MetkaTablePost.limit(1_000).pluck(:id),
625
+ ato: AtoPost.limit(1_000).pluck(:id),
626
+ gutentag: GutentagPost.limit(1_000).pluck(:id)
627
+ }
628
+ if SQLITE
629
+ update_ids[:metka_index] = MetkaIndexPost.limit(1_000).pluck(:id)
630
+ else
631
+ update_ids[:array] = ArrayPost.limit(1_000).pluck(:id)
632
+ update_ids[:tc] = TagColumnsPost.limit(1_000).pluck(:id)
633
+ end
634
+
635
+ def next_id(ids)
636
+ @id_index = (@id_index || 0) + 1
637
+ ids[@id_index % ids.size]
638
+ end
639
+
640
+ Benchmark.ips do |x|
641
+ x.config(warmup: 2, time: 5)
642
+ x.report("metka") do
643
+ p = MetkaPost.find(next_id(update_ids[:metka]))
644
+ p.tag_list = next_create_set
645
+ p.save!
646
+ end
647
+ x.report("metka (table)") do
648
+ p = MetkaTablePost.find(next_id(update_ids[:metka_table]))
649
+ p.tag_list = next_create_set
650
+ p.save!
651
+ end
652
+ if SQLITE
653
+ x.report("metka (index)") do
654
+ p = MetkaIndexPost.find(next_id(update_ids[:metka_index]))
655
+ p.tag_list = next_create_set
656
+ p.save!
657
+ end
658
+ else
659
+ x.report("acts-as-taggable-array-on") do
660
+ ArrayPost.find(next_id(update_ids[:array])).update!(tags: next_create_set)
661
+ end
662
+ x.report("tag_columns") do
663
+ TagColumnsPost.find(next_id(update_ids[:tc])).update!(tags: next_create_set)
664
+ end
665
+ end
666
+ x.report("acts-as-taggable-on") do
667
+ p = AtoPost.find(next_id(update_ids[:ato]))
668
+ p.tag_list = next_create_set
669
+ p.save!
670
+ end
671
+ x.report("gutentag") do
672
+ p = GutentagPost.find(next_id(update_ids[:gutentag]))
673
+ p.tag_names = next_create_set
674
+ p.save!
675
+ end
676
+ x.compare!
677
+ end
678
+
679
+ # --------------------------------------------------------- integrity check ---
680
+
681
+ def cloud_mismatches(source_table, summary_table)
682
+ live_tags =
683
+ if SQLITE
684
+ "SELECT value AS tag_name FROM #{source_table}, json_each(#{source_table}.tags)"
685
+ else
686
+ "SELECT UNNEST(tags) AS tag_name FROM #{source_table}"
687
+ end
688
+ # SQLite has no IS DISTINCT FROM; its IS / IS NOT are the null-safe forms.
689
+ distinct_from = SQLITE ? "IS NOT" : "IS DISTINCT FROM"
690
+
691
+ ActiveRecord::Base.connection.select_value(<<~SQL).to_i
692
+ SELECT COUNT(*)
693
+ FROM (
694
+ SELECT tag_name, COUNT(*) AS cnt
695
+ FROM (#{live_tags}) s
696
+ GROUP BY tag_name
697
+ ) live
698
+ FULL OUTER JOIN #{summary_table} summary USING (tag_name)
699
+ WHERE summary.taggings_count #{distinct_from} live.cnt
700
+ SQL
701
+ end
702
+
703
+ puts
704
+ puts "Strategy integrity after all suites (0 = aggregate matches a live aggregation):"
705
+ puts " table mismatching tags: #{cloud_mismatches('metka_table_posts', 'metka_table_posts_tags_cloud')}"
706
+
707
+ if SQLITE
708
+ index_mismatches = ActiveRecord::Base.connection.select_value(<<~SQL).to_i
709
+ SELECT COUNT(*)
710
+ FROM (
711
+ SELECT DISTINCT value AS tag_name, metka_index_posts.id AS record_id
712
+ FROM metka_index_posts, json_each(metka_index_posts.tags)
713
+ ) live
714
+ FULL OUTER JOIN metka_index_posts_tags_index idx USING (tag_name, record_id)
715
+ WHERE live.record_id IS NULL OR idx.record_id IS NULL
716
+ SQL
717
+ puts " index mismatching pairs: #{index_mismatches}"
718
+ end