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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +273 -236
  3. data/lib/generators/metka/sql_identifier.rb +25 -0
  4. data/lib/generators/metka/strategies/index/index_generator.rb +84 -0
  5. data/lib/generators/metka/strategies/index/templates/migration.rb.erb +89 -0
  6. data/lib/generators/metka/strategies/table/table_generator.rb +91 -0
  7. data/lib/generators/metka/strategies/table/templates/migration.rb.erb +128 -0
  8. data/lib/generators/metka/strategies/table/templates/migration.sqlite.rb.erb +93 -0
  9. data/lib/metka/generic_parser.rb +28 -14
  10. data/lib/metka/model.rb +130 -51
  11. data/lib/metka/query_builder.rb +45 -49
  12. data/lib/metka/tag_list.rb +16 -8
  13. data/lib/metka/tags_query.rb +93 -0
  14. data/lib/metka/version.rb +1 -1
  15. data/lib/metka.rb +24 -10
  16. metadata +35 -154
  17. data/.github/ISSUE_TEMPLATE.md +0 -15
  18. data/.github/workflows/lint_code.yml +0 -21
  19. data/.github/workflows/lint_docs.yml +0 -57
  20. data/.github/workflows/specs.yml +0 -86
  21. data/.gitignore +0 -18
  22. data/.mdlrc +0 -1
  23. data/.rspec +0 -2
  24. data/.rubocop-md.yml +0 -20
  25. data/.rubocop.yml +0 -27
  26. data/.ruby-version +0 -1
  27. data/Gemfile +0 -12
  28. data/Gemfile.lock +0 -239
  29. data/Rakefile +0 -13
  30. data/bin/console +0 -14
  31. data/bin/setup +0 -8
  32. data/forspell.dict +0 -7
  33. data/gemfiles/rails52.gemfile +0 -6
  34. data/gemfiles/rails6.gemfile +0 -6
  35. data/gemfiles/rails61.gemfile +0 -6
  36. data/gemfiles/railsmain.gemfile +0 -5
  37. data/gemfiles/rubocop.gemfile +0 -4
  38. data/lib/generators/metka/strategies/materialized_view/materialized_view_generator.rb +0 -73
  39. data/lib/generators/metka/strategies/materialized_view/templates/migration.rb.erb +0 -54
  40. data/lib/generators/metka/strategies/view/templates/migration.rb.erb +0 -26
  41. data/lib/generators/metka/strategies/view/view_generator.rb +0 -70
  42. data/lib/metka/query_builder/all_tags_query.rb +0 -11
  43. data/lib/metka/query_builder/any_tags_query.rb +0 -11
  44. data/lib/metka/query_builder/base_query.rb +0 -48
  45. 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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'singleton'
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
- @single_quote_pattern ||= {}
17
- @double_quote_pattern ||= {}
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
- value = value.to_s.dup
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 value.split(Regexp.new(delimiter)).map(&:strip).reject(&:empty?)
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
- def gsub_quote_pattern!(tag_list, value, pattern)
38
- value.gsub!(pattern) {
39
- tag_list.add(Regexp.last_match[2])
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] ||= /(\A|#{delimiter})\s*'(.*?)'\s*(?=#{delimiter}\s*|\z)/
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] ||= /(\A|#{delimiter})\s*"(.*?)"\s*(?=#{delimiter}\s*|\z)/
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