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,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
|