metka 2.3.4 → 3.0.1
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/README.md +273 -236
- data/lib/generators/metka/sql_identifier.rb +25 -0
- data/lib/generators/metka/strategies/index/index_generator.rb +84 -0
- data/lib/generators/metka/strategies/index/templates/migration.rb.erb +89 -0
- data/lib/generators/metka/strategies/table/table_generator.rb +91 -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 +130 -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
- metadata +35 -154
- data/.github/ISSUE_TEMPLATE.md +0 -15
- data/.github/workflows/lint_code.yml +0 -21
- data/.github/workflows/lint_docs.yml +0 -57
- data/.github/workflows/specs.yml +0 -86
- data/.gitignore +0 -18
- data/.mdlrc +0 -1
- data/.rspec +0 -2
- data/.rubocop-md.yml +0 -20
- data/.rubocop.yml +0 -27
- data/.ruby-version +0 -1
- data/Gemfile +0 -12
- data/Gemfile.lock +0 -239
- data/Rakefile +0 -13
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/forspell.dict +0 -7
- data/gemfiles/rails52.gemfile +0 -6
- data/gemfiles/rails6.gemfile +0 -6
- data/gemfiles/rails61.gemfile +0 -6
- data/gemfiles/railsmain.gemfile +0 -5
- data/gemfiles/rubocop.gemfile +0 -4
- 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/metka.gemspec +0 -42
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metka
|
|
4
|
+
module Generators
|
|
5
|
+
# Generator options land verbatim in the migration's SQL, so they are
|
|
6
|
+
# constrained to plain identifiers: a hostile-looking name fails closed
|
|
7
|
+
# instead of producing broken or surprising DDL.
|
|
8
|
+
module SqlIdentifier
|
|
9
|
+
IDENTIFIER = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def validate_sql_identifiers!(names_by_option)
|
|
14
|
+
names_by_option.each do |option, names|
|
|
15
|
+
names.each do |name|
|
|
16
|
+
next if IDENTIFIER.match?(name)
|
|
17
|
+
|
|
18
|
+
raise Thor::Error, "#{option} #{name.inspect} must be a plain SQL identifier: " \
|
|
19
|
+
"letters, digits and underscores, not starting with a digit"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "../../sql_identifier"
|
|
6
|
+
|
|
7
|
+
module Metka
|
|
8
|
+
module Generators
|
|
9
|
+
module Strategies
|
|
10
|
+
class IndexGenerator < ::Rails::Generators::Base # :nodoc:
|
|
11
|
+
include Rails::Generators::Migration
|
|
12
|
+
include Metka::Generators::SqlIdentifier
|
|
13
|
+
|
|
14
|
+
DEFAULT_SOURCE_COLUMNS = [ "tags" ].freeze
|
|
15
|
+
|
|
16
|
+
desc <<~LONGDESC
|
|
17
|
+
Generates a migration implementing the SQLite index strategy for
|
|
18
|
+
Metka: one (tag_name, record_id) side table per tagged column,
|
|
19
|
+
maintained by triggers, so tag queries become index seeks instead
|
|
20
|
+
of json_each table scans. Declare the tables on the model with the
|
|
21
|
+
index_tables option to route queries through them.
|
|
22
|
+
|
|
23
|
+
PostgreSQL needs no index strategy — GIN indexes on the array
|
|
24
|
+
columns already serve tag queries — so the generator produces
|
|
25
|
+
nothing there.
|
|
26
|
+
|
|
27
|
+
> $ rails g metka:strategies:index \
|
|
28
|
+
--source-table-name=NAME_OF_TABLE_WITH_TAGS \
|
|
29
|
+
--source-columns=NAME_OF_TAGGED_COLUMN_1 NAME_OF_TAGGED_COLUMN_2
|
|
30
|
+
LONGDESC
|
|
31
|
+
|
|
32
|
+
source_root File.expand_path("templates", __dir__)
|
|
33
|
+
|
|
34
|
+
class_option :source_table_name, type: :string, required: true,
|
|
35
|
+
desc: "Name of the table that has a column with tags"
|
|
36
|
+
|
|
37
|
+
class_option :source_columns, type: :array, default: DEFAULT_SOURCE_COLUMNS,
|
|
38
|
+
desc: "List of the tagged columns names"
|
|
39
|
+
|
|
40
|
+
def generate_migration
|
|
41
|
+
validate_sql_identifiers!(
|
|
42
|
+
"--source-table-name" => [ source_table_name ],
|
|
43
|
+
"--source-columns" => source_columns
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
unless sqlite?
|
|
47
|
+
say_status :skipped,
|
|
48
|
+
"the index strategy targets SQLite; PostgreSQL GIN indexes already serve tag queries",
|
|
49
|
+
:yellow
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
migration_template "migration.rb.erb", "db/migrate/#{migration_name}.rb"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
no_tasks do
|
|
57
|
+
def sqlite?
|
|
58
|
+
::ActiveRecord::Base.connection.adapter_name.match?(/sqlite/i)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def source_table_name
|
|
62
|
+
options[:source_table_name]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def source_columns
|
|
66
|
+
options[:source_columns]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def index_table_for(column)
|
|
70
|
+
"#{source_table_name}_#{column}_index"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def migration_name
|
|
74
|
+
"create_#{source_table_name}_#{source_columns.join('_and_')}_index_tables"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.next_migration_number(dir)
|
|
79
|
+
::ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
|
|
4
|
+
# One (tag_name, record_id) side table per tagged column, so SQLite tag
|
|
5
|
+
# queries become index seeks instead of json_each table scans. The WITHOUT
|
|
6
|
+
# ROWID primary key (tag_name, record_id) is itself the covering index —
|
|
7
|
+
# no separate index needed — and doubles as the dedup guard: a tag
|
|
8
|
+
# duplicated inside one row's array stores a single pair, which is all
|
|
9
|
+
# membership queries need.
|
|
10
|
+
#
|
|
11
|
+
# Per-row triggers keep the pairs in step with every INSERT, UPDATE and
|
|
12
|
+
# DELETE, multi-row statements included. Deletes seek through the primary
|
|
13
|
+
# key (tag_name from OLD's array, then record_id), so no extra index on
|
|
14
|
+
# record_id is required. Writes that bypass the triggers (restoring from a
|
|
15
|
+
# dump) require reseeding the table by hand.
|
|
16
|
+
#
|
|
17
|
+
# Declare the tables on the model to route queries through them:
|
|
18
|
+
#
|
|
19
|
+
# include Metka::Model(
|
|
20
|
+
# columns: %w[<%= source_columns.join(' ') %>],
|
|
21
|
+
# index_tables: {
|
|
22
|
+
<% source_columns.each do |column| -%>
|
|
23
|
+
# "<%= column %>" => "<%= index_table_for(column) %>",
|
|
24
|
+
<% end -%>
|
|
25
|
+
# }
|
|
26
|
+
# )
|
|
27
|
+
def up
|
|
28
|
+
<% source_columns.each do |column| -%>
|
|
29
|
+
execute <<-SQL
|
|
30
|
+
CREATE TABLE <%= index_table_for(column) %> (
|
|
31
|
+
tag_name varchar NOT NULL,
|
|
32
|
+
record_id bigint NOT NULL,
|
|
33
|
+
PRIMARY KEY (tag_name, record_id)
|
|
34
|
+
) WITHOUT ROWID;
|
|
35
|
+
SQL
|
|
36
|
+
|
|
37
|
+
execute <<-SQL
|
|
38
|
+
INSERT OR IGNORE INTO <%= index_table_for(column) %> (tag_name, record_id)
|
|
39
|
+
SELECT value, <%= source_table_name %>.id
|
|
40
|
+
FROM <%= source_table_name %>, json_each(<%= source_table_name %>.<%= column %>);
|
|
41
|
+
SQL
|
|
42
|
+
|
|
43
|
+
execute <<-SQL
|
|
44
|
+
CREATE TRIGGER metka_idx_ins_on_<%= source_table_name %>_<%= column %>
|
|
45
|
+
AFTER INSERT ON <%= source_table_name %>
|
|
46
|
+
FOR EACH ROW
|
|
47
|
+
BEGIN
|
|
48
|
+
INSERT OR IGNORE INTO <%= index_table_for(column) %> (tag_name, record_id)
|
|
49
|
+
SELECT value, NEW.id FROM json_each(NEW.<%= column %>);
|
|
50
|
+
END;
|
|
51
|
+
SQL
|
|
52
|
+
|
|
53
|
+
execute <<-SQL
|
|
54
|
+
CREATE TRIGGER metka_idx_upd_on_<%= source_table_name %>_<%= column %>
|
|
55
|
+
AFTER UPDATE OF <%= column %> ON <%= source_table_name %>
|
|
56
|
+
FOR EACH ROW
|
|
57
|
+
BEGIN
|
|
58
|
+
DELETE FROM <%= index_table_for(column) %>
|
|
59
|
+
WHERE tag_name IN (SELECT value FROM json_each(OLD.<%= column %>))
|
|
60
|
+
AND record_id = OLD.id;
|
|
61
|
+
|
|
62
|
+
INSERT OR IGNORE INTO <%= index_table_for(column) %> (tag_name, record_id)
|
|
63
|
+
SELECT value, NEW.id FROM json_each(NEW.<%= column %>);
|
|
64
|
+
END;
|
|
65
|
+
SQL
|
|
66
|
+
|
|
67
|
+
execute <<-SQL
|
|
68
|
+
CREATE TRIGGER metka_idx_del_on_<%= source_table_name %>_<%= column %>
|
|
69
|
+
AFTER DELETE ON <%= source_table_name %>
|
|
70
|
+
FOR EACH ROW
|
|
71
|
+
BEGIN
|
|
72
|
+
DELETE FROM <%= index_table_for(column) %>
|
|
73
|
+
WHERE tag_name IN (SELECT value FROM json_each(OLD.<%= column %>))
|
|
74
|
+
AND record_id = OLD.id;
|
|
75
|
+
END;
|
|
76
|
+
SQL
|
|
77
|
+
|
|
78
|
+
<% end -%>
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def down
|
|
82
|
+
<% source_columns.each do |column| -%>
|
|
83
|
+
execute "DROP TRIGGER IF EXISTS metka_idx_ins_on_<%= source_table_name %>_<%= column %>;"
|
|
84
|
+
execute "DROP TRIGGER IF EXISTS metka_idx_upd_on_<%= source_table_name %>_<%= column %>;"
|
|
85
|
+
execute "DROP TRIGGER IF EXISTS metka_idx_del_on_<%= source_table_name %>_<%= column %>;"
|
|
86
|
+
execute "DROP TABLE IF EXISTS <%= index_table_for(column) %>;"
|
|
87
|
+
<% end -%>
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "../../sql_identifier"
|
|
6
|
+
|
|
7
|
+
module Metka
|
|
8
|
+
module Generators
|
|
9
|
+
module Strategies
|
|
10
|
+
class TableGenerator < ::Rails::Generators::Base # :nodoc:
|
|
11
|
+
include Rails::Generators::Migration
|
|
12
|
+
include Metka::Generators::SqlIdentifier
|
|
13
|
+
|
|
14
|
+
DEFAULT_SOURCE_COLUMNS = [ "tags" ].freeze
|
|
15
|
+
|
|
16
|
+
desc <<~LONGDESC
|
|
17
|
+
Generates migration to implement table strategy for Metka
|
|
18
|
+
|
|
19
|
+
> $ rails g metka:strategies:table \
|
|
20
|
+
--source-table-name=NAME_OF_TABLE_WITH_TAGS \
|
|
21
|
+
--source-columns=NAME_OF_TAGGED_COLUMN_1 NAME_OF_TAGGED_COLUMN_2 \
|
|
22
|
+
--table-name=NAME_OF_TABLE
|
|
23
|
+
LONGDESC
|
|
24
|
+
|
|
25
|
+
source_root File.expand_path("templates", __dir__)
|
|
26
|
+
|
|
27
|
+
class_option :source_table_name, type: :string, required: true,
|
|
28
|
+
desc: "Name of the table that has a column with tags"
|
|
29
|
+
|
|
30
|
+
class_option :source_columns, type: :array, default: DEFAULT_SOURCE_COLUMNS,
|
|
31
|
+
desc: "List of the tagged columns names"
|
|
32
|
+
|
|
33
|
+
class_option :table_name, type: :string,
|
|
34
|
+
desc: "Custom name for the resulting table"
|
|
35
|
+
|
|
36
|
+
def generate_migration
|
|
37
|
+
validate_sql_identifiers!(
|
|
38
|
+
"--source-table-name" => [ source_table_name ],
|
|
39
|
+
"--source-columns" => source_columns,
|
|
40
|
+
"--table-name" => Array(options[:table_name])
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
migration_template migration_template_file, "db/migrate/#{migration_name}.rb"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
no_tasks do
|
|
47
|
+
# The migration is written for the database the app is connected to
|
|
48
|
+
# at generation time: transition-table triggers for PostgreSQL,
|
|
49
|
+
# per-row json_each triggers for SQLite.
|
|
50
|
+
def migration_template_file
|
|
51
|
+
if ::ActiveRecord::Base.connection.adapter_name.match?(/sqlite/i)
|
|
52
|
+
"migration.sqlite.rb.erb"
|
|
53
|
+
else
|
|
54
|
+
"migration.rb.erb"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def source_table_name
|
|
59
|
+
options[:source_table_name]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def source_columns
|
|
63
|
+
options[:source_columns]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def source_columns_names
|
|
67
|
+
source_columns.join("_and_")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def table_name
|
|
71
|
+
return options[:table_name] if options[:table_name]
|
|
72
|
+
|
|
73
|
+
"#{source_table_name}_#{source_columns_names}_cloud"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def migration_name
|
|
77
|
+
"create_#{table_name}_table"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def migration_class_name
|
|
81
|
+
migration_name.classify
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.next_migration_number(dir)
|
|
86
|
+
::ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
|
|
4
|
+
def up
|
|
5
|
+
execute <<-SQL
|
|
6
|
+
-- A real table maintained by per-tag deltas: statement-level triggers read
|
|
7
|
+
-- the transition tables and upsert only the tags the statement touched, so
|
|
8
|
+
-- maintenance cost is O(tags touched) instead of a full recompute. Writes
|
|
9
|
+
-- that bypass these triggers (TRUNCATE, restoring from a dump) require
|
|
10
|
+
-- reseeding the table by hand.
|
|
11
|
+
|
|
12
|
+
-- Block writes (reads stay unblocked) until the triggers exist: a write
|
|
13
|
+
-- committing between the seed's snapshot and CREATE TRIGGER would be seen
|
|
14
|
+
-- by neither and drift the counts. CREATE TRIGGER takes this same lock
|
|
15
|
+
-- level anyway; this only takes it before the seed instead of after.
|
|
16
|
+
LOCK TABLE <%= source_table_name %> IN SHARE ROW EXCLUSIVE MODE;
|
|
17
|
+
|
|
18
|
+
CREATE TABLE <%= table_name %> (
|
|
19
|
+
tag_name varchar PRIMARY KEY,
|
|
20
|
+
taggings_count bigint NOT NULL
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
INSERT INTO <%= table_name %> (tag_name, taggings_count)
|
|
24
|
+
SELECT
|
|
25
|
+
tag_name,
|
|
26
|
+
COUNT(*) AS taggings_count
|
|
27
|
+
FROM (
|
|
28
|
+
SELECT UNNEST
|
|
29
|
+
(<%= source_columns.join(' || ') %>) AS tag_name
|
|
30
|
+
FROM
|
|
31
|
+
<%= source_table_name %>
|
|
32
|
+
) subquery
|
|
33
|
+
GROUP BY
|
|
34
|
+
tag_name;
|
|
35
|
+
|
|
36
|
+
-- UNNEST of a NULL array yields no rows and array concatenation treats a
|
|
37
|
+
-- NULL operand as empty, so NULL tagged columns need no explicit guards.
|
|
38
|
+
-- One function per operation: a transition table is only registered for
|
|
39
|
+
-- its own trigger, so the other operations' functions would fail to parse.
|
|
40
|
+
CREATE OR REPLACE FUNCTION metka_ins_<%= table_name %>() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
41
|
+
BEGIN
|
|
42
|
+
INSERT INTO <%= table_name %> (tag_name, taggings_count)
|
|
43
|
+
SELECT tag_name, COUNT(*)
|
|
44
|
+
FROM (
|
|
45
|
+
SELECT UNNEST (<%= source_columns.join(' || ') %>) AS tag_name
|
|
46
|
+
FROM new_rows
|
|
47
|
+
) subquery
|
|
48
|
+
GROUP BY tag_name
|
|
49
|
+
ON CONFLICT (tag_name)
|
|
50
|
+
DO UPDATE SET taggings_count = <%= table_name %>.taggings_count + EXCLUDED.taggings_count;
|
|
51
|
+
RETURN NULL;
|
|
52
|
+
END $$;
|
|
53
|
+
|
|
54
|
+
CREATE OR REPLACE FUNCTION metka_upd_<%= table_name %>() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
55
|
+
BEGIN
|
|
56
|
+
WITH deltas AS (
|
|
57
|
+
SELECT tag_name, SUM(d) AS delta
|
|
58
|
+
FROM (
|
|
59
|
+
SELECT UNNEST (<%= source_columns.join(' || ') %>) AS tag_name, 1 AS d FROM new_rows
|
|
60
|
+
UNION ALL
|
|
61
|
+
SELECT UNNEST (<%= source_columns.join(' || ') %>) AS tag_name, -1 AS d FROM old_rows
|
|
62
|
+
) changes
|
|
63
|
+
GROUP BY tag_name
|
|
64
|
+
HAVING SUM(d) <> 0
|
|
65
|
+
)
|
|
66
|
+
INSERT INTO <%= table_name %> (tag_name, taggings_count)
|
|
67
|
+
SELECT tag_name, delta FROM deltas
|
|
68
|
+
ON CONFLICT (tag_name)
|
|
69
|
+
DO UPDATE SET taggings_count = <%= table_name %>.taggings_count + EXCLUDED.taggings_count;
|
|
70
|
+
|
|
71
|
+
DELETE FROM <%= table_name %> WHERE taggings_count <= 0;
|
|
72
|
+
RETURN NULL;
|
|
73
|
+
END $$;
|
|
74
|
+
|
|
75
|
+
CREATE OR REPLACE FUNCTION metka_del_<%= table_name %>() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
76
|
+
BEGIN
|
|
77
|
+
UPDATE <%= table_name %>
|
|
78
|
+
SET taggings_count = <%= table_name %>.taggings_count - removed.taggings_count
|
|
79
|
+
FROM (
|
|
80
|
+
SELECT tag_name, COUNT(*) AS taggings_count
|
|
81
|
+
FROM (
|
|
82
|
+
SELECT UNNEST (<%= source_columns.join(' || ') %>) AS tag_name
|
|
83
|
+
FROM old_rows
|
|
84
|
+
) subquery
|
|
85
|
+
GROUP BY tag_name
|
|
86
|
+
) removed
|
|
87
|
+
WHERE <%= table_name %>.tag_name = removed.tag_name;
|
|
88
|
+
|
|
89
|
+
DELETE FROM <%= table_name %> WHERE taggings_count <= 0;
|
|
90
|
+
RETURN NULL;
|
|
91
|
+
END $$;
|
|
92
|
+
|
|
93
|
+
-- The ins/upd/del discriminator sits at the front of the trigger names so
|
|
94
|
+
-- they stay distinct even when PostgreSQL truncates identifiers to 63
|
|
95
|
+
-- characters. One trigger per operation: a trigger with transition tables
|
|
96
|
+
-- must be declared for exactly one event.
|
|
97
|
+
CREATE TRIGGER metka_ins_on_<%= source_table_name %>_<%= source_columns_names %>
|
|
98
|
+
AFTER INSERT ON <%= source_table_name %>
|
|
99
|
+
REFERENCING NEW TABLE AS new_rows
|
|
100
|
+
FOR EACH STATEMENT
|
|
101
|
+
EXECUTE PROCEDURE metka_ins_<%= table_name %>();
|
|
102
|
+
|
|
103
|
+
CREATE TRIGGER metka_upd_on_<%= source_table_name %>_<%= source_columns_names %>
|
|
104
|
+
AFTER UPDATE ON <%= source_table_name %>
|
|
105
|
+
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
|
|
106
|
+
FOR EACH STATEMENT
|
|
107
|
+
EXECUTE PROCEDURE metka_upd_<%= table_name %>();
|
|
108
|
+
|
|
109
|
+
CREATE TRIGGER metka_del_on_<%= source_table_name %>_<%= source_columns_names %>
|
|
110
|
+
AFTER DELETE ON <%= source_table_name %>
|
|
111
|
+
REFERENCING OLD TABLE AS old_rows
|
|
112
|
+
FOR EACH STATEMENT
|
|
113
|
+
EXECUTE PROCEDURE metka_del_<%= table_name %>();
|
|
114
|
+
SQL
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def down
|
|
118
|
+
execute <<-SQL
|
|
119
|
+
DROP TRIGGER IF EXISTS metka_ins_on_<%= source_table_name %>_<%= source_columns_names %> ON <%= source_table_name %>;
|
|
120
|
+
DROP TRIGGER IF EXISTS metka_upd_on_<%= source_table_name %>_<%= source_columns_names %> ON <%= source_table_name %>;
|
|
121
|
+
DROP TRIGGER IF EXISTS metka_del_on_<%= source_table_name %>_<%= source_columns_names %> ON <%= source_table_name %>;
|
|
122
|
+
DROP FUNCTION IF EXISTS metka_ins_<%= table_name %>;
|
|
123
|
+
DROP FUNCTION IF EXISTS metka_upd_<%= table_name %>;
|
|
124
|
+
DROP FUNCTION IF EXISTS metka_del_<%= table_name %>;
|
|
125
|
+
DROP TABLE IF EXISTS <%= table_name %>;
|
|
126
|
+
SQL
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
|
|
4
|
+
# A real table maintained by per-tag deltas. SQLite has no statement-level
|
|
5
|
+
# triggers or transition tables, so per-row triggers read NEW/OLD
|
|
6
|
+
# through json_each and apply the deltas one row at a time; multi-row
|
|
7
|
+
# statements (insert_all, update_all, delete_all) still keep exact counts
|
|
8
|
+
# because the trigger fires once per affected row. SQLite allows one writer
|
|
9
|
+
# per database and DDL is transactional, so nothing can write between the
|
|
10
|
+
# seed and CREATE TRIGGER and no explicit lock is needed. Writes that bypass
|
|
11
|
+
# the triggers (restoring from a dump) require reseeding the table by hand.
|
|
12
|
+
#
|
|
13
|
+
# Requires SQLite >= 3.35 for ON CONFLICT inside a trigger body. The
|
|
14
|
+
# WHERE true in the upsert disambiguates ON CONFLICT from a join clause in
|
|
15
|
+
# the INSERT ... SELECT form — a documented SQLite parser requirement.
|
|
16
|
+
# json_each(NULL) yields no rows, so NULL tagged columns need no guards, and
|
|
17
|
+
# a tag duplicated inside one row's array upserts once per occurrence —
|
|
18
|
+
# counted exactly as PostgreSQL's UNNEST-based triggers count it.
|
|
19
|
+
def up
|
|
20
|
+
execute <<-SQL
|
|
21
|
+
CREATE TABLE <%= table_name %> (
|
|
22
|
+
tag_name varchar PRIMARY KEY,
|
|
23
|
+
taggings_count bigint NOT NULL
|
|
24
|
+
);
|
|
25
|
+
SQL
|
|
26
|
+
|
|
27
|
+
execute <<-SQL
|
|
28
|
+
INSERT INTO <%= table_name %> (tag_name, taggings_count)
|
|
29
|
+
SELECT tag_name, COUNT(*)
|
|
30
|
+
FROM (
|
|
31
|
+
<%= source_columns.map { |column| "SELECT value AS tag_name FROM #{source_table_name}, json_each(#{source_table_name}.#{column})" }.join("\n UNION ALL\n ") %>
|
|
32
|
+
)
|
|
33
|
+
GROUP BY tag_name;
|
|
34
|
+
SQL
|
|
35
|
+
|
|
36
|
+
execute <<-SQL
|
|
37
|
+
CREATE TRIGGER metka_ins_on_<%= source_table_name %>_<%= source_columns_names %>
|
|
38
|
+
AFTER INSERT ON <%= source_table_name %>
|
|
39
|
+
FOR EACH ROW
|
|
40
|
+
BEGIN
|
|
41
|
+
<% source_columns.each do |column| -%>
|
|
42
|
+
INSERT INTO <%= table_name %> (tag_name, taggings_count)
|
|
43
|
+
SELECT value, 1 FROM json_each(NEW.<%= column %>) WHERE true
|
|
44
|
+
ON CONFLICT (tag_name)
|
|
45
|
+
DO UPDATE SET taggings_count = taggings_count + 1;
|
|
46
|
+
<% end -%>
|
|
47
|
+
END;
|
|
48
|
+
SQL
|
|
49
|
+
|
|
50
|
+
execute <<-SQL
|
|
51
|
+
CREATE TRIGGER metka_upd_on_<%= source_table_name %>_<%= source_columns_names %>
|
|
52
|
+
AFTER UPDATE OF <%= source_columns.join(", ") %> ON <%= source_table_name %>
|
|
53
|
+
FOR EACH ROW
|
|
54
|
+
BEGIN
|
|
55
|
+
<% source_columns.each do |column| -%>
|
|
56
|
+
INSERT INTO <%= table_name %> (tag_name, taggings_count)
|
|
57
|
+
SELECT value, 1 FROM json_each(NEW.<%= column %>) WHERE true
|
|
58
|
+
ON CONFLICT (tag_name)
|
|
59
|
+
DO UPDATE SET taggings_count = taggings_count + 1;
|
|
60
|
+
<% end -%>
|
|
61
|
+
<% source_columns.each do |column| -%>
|
|
62
|
+
UPDATE <%= table_name %>
|
|
63
|
+
SET taggings_count = taggings_count -
|
|
64
|
+
(SELECT COUNT(*) FROM json_each(OLD.<%= column %>) WHERE value = tag_name)
|
|
65
|
+
WHERE tag_name IN (SELECT value FROM json_each(OLD.<%= column %>));
|
|
66
|
+
<% end -%>
|
|
67
|
+
DELETE FROM <%= table_name %> WHERE taggings_count <= 0;
|
|
68
|
+
END;
|
|
69
|
+
SQL
|
|
70
|
+
|
|
71
|
+
execute <<-SQL
|
|
72
|
+
CREATE TRIGGER metka_del_on_<%= source_table_name %>_<%= source_columns_names %>
|
|
73
|
+
AFTER DELETE ON <%= source_table_name %>
|
|
74
|
+
FOR EACH ROW
|
|
75
|
+
BEGIN
|
|
76
|
+
<% source_columns.each do |column| -%>
|
|
77
|
+
UPDATE <%= table_name %>
|
|
78
|
+
SET taggings_count = taggings_count -
|
|
79
|
+
(SELECT COUNT(*) FROM json_each(OLD.<%= column %>) WHERE value = tag_name)
|
|
80
|
+
WHERE tag_name IN (SELECT value FROM json_each(OLD.<%= column %>));
|
|
81
|
+
<% end -%>
|
|
82
|
+
DELETE FROM <%= table_name %> WHERE taggings_count <= 0;
|
|
83
|
+
END;
|
|
84
|
+
SQL
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def down
|
|
88
|
+
execute "DROP TRIGGER IF EXISTS metka_ins_on_<%= source_table_name %>_<%= source_columns_names %>;"
|
|
89
|
+
execute "DROP TRIGGER IF EXISTS metka_upd_on_<%= source_table_name %>_<%= source_columns_names %>;"
|
|
90
|
+
execute "DROP TRIGGER IF EXISTS metka_del_on_<%= source_table_name %>_<%= source_columns_names %>;"
|
|
91
|
+
execute "DROP TABLE IF EXISTS <%= table_name %>;"
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/metka/generic_parser.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "singleton"
|
|
4
4
|
|
|
5
5
|
module Metka
|
|
6
6
|
##
|
|
@@ -13,19 +13,24 @@ module Metka
|
|
|
13
13
|
include Singleton
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
16
|
-
@
|
|
17
|
-
@
|
|
16
|
+
@separator = {}
|
|
17
|
+
@single_quote_pattern = {}
|
|
18
|
+
@double_quote_pattern = {}
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def call(value)
|
|
22
|
+
# A TagList is this parser's own output, so it is already split,
|
|
23
|
+
# stripped, and de-duplicated — hand it back rather than rebuilding it.
|
|
24
|
+
return value if value.is_a?(TagList)
|
|
25
|
+
|
|
21
26
|
TagList.new.tap do |tag_list|
|
|
22
27
|
case value
|
|
23
28
|
when String
|
|
24
|
-
|
|
25
|
-
gsub_quote_pattern!(tag_list, value, double_quote_pattern)
|
|
26
|
-
gsub_quote_pattern!(tag_list, value, single_quote_pattern)
|
|
29
|
+
unquoted = value.dup
|
|
27
30
|
|
|
28
|
-
tag_list.merge
|
|
31
|
+
tag_list.merge extract_quoted!(unquoted, double_quote_pattern)
|
|
32
|
+
tag_list.merge extract_quoted!(unquoted, single_quote_pattern)
|
|
33
|
+
tag_list.merge unquoted.split(separator).map(&:strip).reject(&:empty?)
|
|
29
34
|
when Enumerable
|
|
30
35
|
tag_list.merge value.reject(&:empty?)
|
|
31
36
|
end
|
|
@@ -34,23 +39,32 @@ module Metka
|
|
|
34
39
|
|
|
35
40
|
private
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
+
# Returns the quoted tags and strips them out of +text+, which is left
|
|
43
|
+
# holding only the unquoted remainder for the delimiter split to handle.
|
|
44
|
+
def extract_quoted!(text, pattern)
|
|
45
|
+
tags = []
|
|
46
|
+
text.gsub!(pattern) { tags << Regexp.last_match[:tag]; "" }
|
|
47
|
+
tags
|
|
42
48
|
end
|
|
43
49
|
|
|
44
50
|
def delimiter
|
|
45
51
|
Metka.delimiter
|
|
46
52
|
end
|
|
47
53
|
|
|
54
|
+
# The delimiter is a literal separator, so it is escaped before going
|
|
55
|
+
# anywhere near a Regexp. Without this a delimiter of "|" becomes an empty
|
|
56
|
+
# alternation matching between every character, and "." matches every
|
|
57
|
+
# character — both silently shredding the input instead of splitting it.
|
|
58
|
+
def separator
|
|
59
|
+
@separator[delimiter] ||= Regexp.new(Regexp.escape(delimiter))
|
|
60
|
+
end
|
|
61
|
+
|
|
48
62
|
def single_quote_pattern
|
|
49
|
-
@single_quote_pattern[delimiter] ||= /(
|
|
63
|
+
@single_quote_pattern[delimiter] ||= /(?:\A|#{Regexp.escape(delimiter)})\s*'(?<tag>.*?)'\s*(?=#{Regexp.escape(delimiter)}\s*|\z)/
|
|
50
64
|
end
|
|
51
65
|
|
|
52
66
|
def double_quote_pattern
|
|
53
|
-
@double_quote_pattern[delimiter] ||= /(
|
|
67
|
+
@double_quote_pattern[delimiter] ||= /(?:\A|#{Regexp.escape(delimiter)})\s*"(?<tag>.*?)"\s*(?=#{Regexp.escape(delimiter)}\s*|\z)/
|
|
54
68
|
end
|
|
55
69
|
end
|
|
56
70
|
end
|