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
@@ -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
data/lib/metka/model.rb CHANGED
@@ -1,88 +1,158 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'arel'
3
+ require "arel"
4
4
 
5
5
  module Metka
6
- OR = Arel::Nodes::Or
7
- AND = Arel::Nodes::And
8
-
9
6
  def self.Model(column: nil, columns: nil, **options)
10
- columns = [column, *columns].uniq.compact
11
- raise ArgumentError, 'Columns not specified' unless columns.present?
7
+ columns = [ column, *columns ].uniq.compact
8
+ raise ArgumentError, "Columns not specified" unless columns.present?
12
9
 
13
10
  Metka::Model.new(columns: columns, **options)
14
11
  end
15
12
 
16
13
  class Model < Module
14
+ TAGGED_WITH_OPTIONS = %i[any exclude join_operator on].freeze
15
+
17
16
  def initialize(columns:, **options)
18
17
  @columns = columns.dup.freeze
19
18
  @options = options.dup.freeze
19
+
20
+ unknown = index_tables.keys - @columns
21
+ if unknown.any?
22
+ raise ArgumentError, "index_tables declared for unknown columns #{unknown.inspect}, expected #{@columns.inspect}"
23
+ end
20
24
  end
21
25
 
22
26
  def included(base)
23
- columns = @columns
24
- parser = ->(tags) {
25
- @options[:parser] ? @options[:parser].call(tags) : Metka.config.parser.instance.call(tags)
26
- }
27
+ define_column_scopes(base)
28
+ define_tagged_with_scope(base)
29
+ define_index_tables(base)
30
+ define_tag_clouds(base)
31
+ define_tag_list_accessors(base)
32
+ end
27
33
 
28
- # @param model [ActiveRecord::Base] model on which to execute search
29
- # @param tags [Object] list of tags, representation depends on parser used
30
- # @param options [Hash] options
31
- # @option :join_operator [Metka::AND, Metka::OR]
32
- # @option :on [Array<String>] list of column names to include in query
33
- # @returns ViewPost::ActiveRecord_Relation
34
- tagged_with_lambda = ->(model, tags, **options) {
35
- cols = options.delete(:on)
36
- parsed_tag_list = parser.call(tags)
34
+ private
37
35
 
38
- return model if parsed_tag_list.empty?
36
+ def define_column_scopes(base)
37
+ @columns.each do |column|
38
+ base.scope "with_all_#{column}", ->(tags) { tagged_with(tags, on: [ column ]) }
39
+ base.scope "with_any_#{column}", ->(tags) { tagged_with(tags, on: [ column ], any: true) }
40
+ base.scope "without_all_#{column}", ->(tags) { tagged_with(tags, on: [ column ], exclude: true) }
41
+ base.scope "without_any_#{column}", ->(tags) { tagged_with(tags, on: [ column ], any: true, exclude: true) }
42
+ end
43
+ end
39
44
 
40
- request = ::Metka::QueryBuilder.new.call(model, cols, parsed_tag_list, options)
41
- model.where(request)
42
- }
45
+ # @param tags [Object] list of tags, representation depends on the parser used
46
+ # @param options [Hash] options
47
+ # @option :any [Boolean] match any of the tags instead of all of them
48
+ # @option :exclude [Boolean] negate the match
49
+ # @option :join_operator [Metka::AND, Metka::OR] how to combine multiple columns
50
+ # @option :on [Array<String>] column names to search
51
+ # @return [ActiveRecord::Relation]
52
+ def define_tagged_with_scope(base)
53
+ return if base.respond_to?(:tagged_with)
43
54
 
44
- base.class_eval do
45
- columns.each do |column|
46
- scope "with_all_#{column}", ->(tags) { tagged_with(tags, on: [column]) }
47
- scope "with_any_#{column}", ->(tags) { tagged_with(tags, on: [column], any: true) }
48
- scope "without_all_#{column}", ->(tags) { tagged_with(tags, on: [column], exclude: true) }
49
- scope "without_any_#{column}", ->(tags) { tagged_with(tags, on: [column], any: true, exclude: true) }
55
+ columns = @columns
56
+ parser = tag_parser
57
+ allowed = TAGGED_WITH_OPTIONS
58
+
59
+ # legacy_options carries the positional hash callers could pass before
60
+ # these became keywords. Ruby 3 will not convert one into keywords, so
61
+ # accepting it explicitly keeps `tagged_with(tags, options)` working.
62
+ base.scope :tagged_with, ->(tags = "", legacy_options = nil, **options) {
63
+ options = legacy_options.to_h.symbolize_keys.merge(options)
64
+ unknown = options.keys - allowed
65
+ if unknown.any?
66
+ raise ArgumentError, "Unknown tagged_with options #{unknown.inspect}, expected #{allowed.inspect}"
50
67
  end
51
68
 
52
- unless respond_to?(:tagged_with)
53
- scope :tagged_with, ->(tags = '', options = {}) {
54
- options[:join_operator] ||= ::Metka::OR
55
- options = {any: false}.merge(options)
56
- options[:on] ||= columns
69
+ tag_list = parser.call(tags)
70
+ next self if tag_list.empty?
57
71
 
58
- tagged_with_lambda.call(self, tags, **options)
59
- }
60
- end
61
- end
72
+ where(::Metka::QueryBuilder.instance.call(self, options[:on] || columns, tag_list, {
73
+ any: options.fetch(:any, false),
74
+ exclude: options[:exclude],
75
+ join_operator: options[:join_operator] || ::Metka::OR
76
+ }))
77
+ }
78
+ end
62
79
 
63
- base.define_singleton_method :metka_cloud do |*columns|
64
- return [] if columns.blank?
80
+ # The index strategy (`rails g metka:strategies:index`) maintains a
81
+ # (tag_name, record_id) side table per tagged column. Declaring it here
82
+ # via `index_tables: { "tags" => "posts_tags_index" }` lets the SQLite
83
+ # query path answer from that table instead of scanning json_each.
84
+ def define_index_tables(base)
85
+ tables = index_tables
65
86
 
66
- prepared_unnest = columns.map { |column| "#{table_name}.#{column}" }.join(' || ')
67
- subquery = all.select("UNNEST(#{prepared_unnest}) AS tag_name")
87
+ base.define_singleton_method(:metka_index_table) { |column| tables[column.to_s] }
88
+ end
68
89
 
69
- unscoped.from(subquery).group(:tag_name).pluck(:tag_name, Arel.sql('COUNT(*) AS taggings_count'))
90
+ def define_tag_clouds(base)
91
+ taggable = @columns
92
+
93
+ # metka_cloud is public and its arguments land in raw SQL, so they are
94
+ # checked against the declared columns rather than interpolated on trust.
95
+ base.define_singleton_method :metka_cloud do |*cloud_columns|
96
+ return [] if cloud_columns.blank?
97
+
98
+ cloud_columns = cloud_columns.map(&:to_s)
99
+ unknown = cloud_columns - taggable
100
+ if unknown.any?
101
+ raise ArgumentError, "Unknown tag columns #{unknown.inspect}, expected #{taggable.inspect}"
102
+ end
103
+
104
+ quoted = cloud_columns.map { |column|
105
+ connection.quote_table_name("#{table_name}.#{column}")
106
+ }
107
+
108
+ subquery =
109
+ if connection.adapter_name.match?(/sqlite/i)
110
+ # SQLite has no UNNEST; json_each unpacks each JSON array as a
111
+ # lateral cross join. Arrays cannot be concatenated the way
112
+ # PostgreSQL concatenates them, so multiple columns become one
113
+ # SELECT per column glued with UNION ALL — a NULL column joins to
114
+ # zero rows either way, matching UNNEST of a NULL array.
115
+ parts = quoted.map { |column|
116
+ all.select("json_each.value AS tag_name").joins("CROSS JOIN json_each(#{column})").to_sql
117
+ }
118
+ Arel.sql("(#{parts.join(" UNION ALL ")}) subquery")
119
+ else
120
+ all.select("UNNEST(#{quoted.join(" || ")}) AS tag_name")
121
+ end
122
+
123
+ unscoped.from(subquery).group(:tag_name).pluck(:tag_name, Arel.sql("COUNT(*) AS taggings_count"))
70
124
  end
71
125
 
72
- columns.each do |column|
73
- base.define_method(column.singularize + '_list=') do |v|
74
- write_attribute(column, parser.call(v).to_a)
126
+ @columns.each do |column|
127
+ base.define_singleton_method(:"#{column.singularize}_cloud") { metka_cloud(column) }
128
+ end
129
+ end
130
+
131
+ def define_tag_list_accessors(base)
132
+ parser = tag_parser
133
+
134
+ @columns.each do |column|
135
+ base.define_method(:"#{column.singularize}_list=") do |tags|
136
+ write_attribute(column, parser.call(tags).to_a)
75
137
  write_attribute(column, nil) if send(column).empty?
76
138
  end
77
139
 
78
- base.define_method(column.singularize + '_list') do
140
+ base.define_method(:"#{column.singularize}_list") do
79
141
  parser.call(send(column))
80
142
  end
81
-
82
- base.define_singleton_method :"#{column.singularize}_cloud" do
83
- metka_cloud(column)
84
- end
85
143
  end
86
144
  end
145
+
146
+ def index_tables
147
+ (@options[:index_tables] || {}).transform_keys(&:to_s).transform_values(&:to_s).freeze
148
+ end
149
+
150
+ # Metka.config.parser is looked up on every call so that reconfiguring it
151
+ # after a model has been included still takes effect.
152
+ def tag_parser
153
+ custom = @options[:parser]
154
+
155
+ ->(tags) { (custom || Metka.config.parser.instance).call(tags) }
156
+ end
87
157
  end
88
158
  end
@@ -1,70 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'arel'
4
- require_relative 'query_builder/base_query'
5
- require_relative 'query_builder/any_tags_query'
6
- require_relative 'query_builder/all_tags_query'
3
+ require "arel"
4
+ require_relative "tags_query"
7
5
 
8
6
  module Metka
9
7
  class QueryBuilder
10
- def call(model, columns, tags, options)
11
- strategy = options_to_strategy(options)
12
-
13
- query = join(options[:join_operator]) {
14
- columns.map do |column|
15
- build_query(strategy, model, column, tags)
16
- end
17
- }
18
-
19
- if options[:exclude].present?
20
- Arel::Nodes::Not.new(query)
21
- else
22
- query
23
- end
8
+ # Stateless, so one frozen instance serves every tagged_with call.
9
+ def self.instance
10
+ @instance ||= new.freeze
24
11
  end
25
12
 
26
- private
13
+ STRATEGIES = {
14
+ all: TagsQuery.new(match: :all).freeze,
15
+ any: TagsQuery.new(match: :any).freeze
16
+ }.freeze
27
17
 
28
- def options_to_strategy(options)
29
- if options[:any].present?
30
- AnyTagsQuery
31
- else
32
- AllTagsQuery
33
- end
34
- end
18
+ JOINERS = {
19
+ and: ->(nodes) { Arel::Nodes::And.new(nodes) },
20
+ or: ->(nodes) { nodes.reduce(:or) }
21
+ }.freeze
22
+
23
+ # Metka::AND and Metka::OR used to be these Arel classes. Callers that
24
+ # passed them literally still work.
25
+ LEGACY_OPERATORS = {
26
+ Arel::Nodes::And => :and,
27
+ Arel::Nodes::Or => :or
28
+ }.freeze
35
29
 
36
- def join(operator, &block)
37
- nodes = block.call
30
+ def call(model, columns, tags, options)
31
+ strategy = STRATEGIES.fetch(options[:any].present? ? :any : :all)
32
+ nodes = columns.map { |column| strategy.call(model, column, tags) }
33
+ query = join(nodes, using: options[:join_operator])
38
34
 
39
- if operator == ::Metka::AND
40
- join_and(nodes)
41
- elsif operator == ::Metka::OR
42
- join_or(nodes)
43
- end
35
+ options[:exclude].present? ? exclude(query) : query
44
36
  end
45
37
 
46
- # @param nodes [Array<Arel::Nodes::Node>, Arel::Nodes::Node]
47
- # @return [Arel::Nodes::Node]
48
- def join_or(nodes)
49
- node_base_klass = defined?(::Arel::Nodes::Node) ? ::Arel::Nodes::Node : ::Arel::Node
38
+ private
50
39
 
51
- case nodes
52
- when node_base_klass
53
- nodes
54
- when Array
55
- l, *r = nodes
56
- return l if r.empty?
40
+ def join(nodes, using:)
41
+ raise ArgumentError, "No tag columns to search" if nodes.empty?
57
42
 
58
- l.or(join_or(r))
59
- end
43
+ JOINERS.fetch(normalize(using)) {
44
+ raise ArgumentError,
45
+ "Unknown join_operator #{using.inspect}, expected #{JOINERS.keys.map(&:inspect).join(" or ")}"
46
+ }.call(nodes)
60
47
  end
61
48
 
62
- def join_and(queries)
63
- Arel::Nodes::And.new(queries)
49
+ def normalize(operator)
50
+ LEGACY_OPERATORS.fetch(operator, operator)
64
51
  end
65
52
 
66
- def build_query(strategy, model, column, tags)
67
- strategy.instance.call(model, column, tags)
53
+ # A NULL tag column makes its comparison NULL, and NOT NULL is NULL, so the
54
+ # row silently drops out of the WHERE entirely. Coalescing to FALSE first
55
+ # reads a NULL column as "this row does not carry the tag", which is what
56
+ # excluding actually asks.
57
+ #
58
+ # Only the exclude path is wrapped. Coalescing the columns themselves would
59
+ # fix it too, but costs the GIN index on every positive query.
60
+ def exclude(query)
61
+ Arel::Nodes::Not.new(
62
+ Arel::Nodes::NamedFunction.new("COALESCE", [ query, Arel.sql("FALSE") ])
63
+ )
68
64
  end
69
65
  end
70
66
  end
@@ -1,15 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
3
+ require "set"
4
4
 
5
5
  module Metka
6
+ # The parsed form of a tag string: an ordered, de-duplicated set of tags.
7
+ #
8
+ # Metka::GenericParser.instance.call("ruby, rails, ruby").to_s
9
+ # #=> "ruby, rails"
6
10
  class TagList < Set
7
- # def add(o)
8
- # if o.respond_to?(:each)
9
- # o.each { |e| Metka.config.parser.call(e) }
10
- # else
11
- # super(Metka.config.parser.call(o))
12
- # end
13
- # end
11
+ # Set#to_s is an alias of #inspect, so a tag list rendered itself as
12
+ # "#<Set: {\"ruby\"}>". This is a user-facing value, so render it the way
13
+ # it was written.
14
+ #
15
+ # Joins on the configured delimiter, so it round-trips through the parser.
16
+ # A parser subclass that overrides #delimiter privately — as the dummy
17
+ # app's CustomParser does — is not visible from here, so a tag list it
18
+ # produced renders with the configured delimiter rather than that parser's.
19
+ def to_s
20
+ to_a.join("#{Metka.delimiter} ")
21
+ end
14
22
  end
15
23
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metka
4
+ class TagsQuery
5
+ # PostgreSQL array operators; also the source of truth for valid match
6
+ # modes, so an unknown mode raises KeyError on either adapter.
7
+ OPERATORS = { all: "@>", any: "&&" }.freeze
8
+
9
+ def initialize(match: :all)
10
+ OPERATORS.fetch(match)
11
+ @match = match
12
+ end
13
+
14
+ def call(model, column_name, tag_list)
15
+ if model.connection.adapter_name.match?(/sqlite/i)
16
+ sqlite(model, column_name, tag_list)
17
+ else
18
+ postgresql(model, column_name, tag_list)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ # Always the operator form, even for one tag: `tag = ANY(column)` cannot
25
+ # use a GIN index on the column, while `column @> ARRAY[tag]` can.
26
+ def postgresql(model, column_name, tag_list)
27
+ Arel::Nodes::InfixOperation.new(
28
+ OPERATORS.fetch(@match),
29
+ model.arel_table[column_name],
30
+ literal("ARRAY[?]::varchar[]", tag_list.to_a)
31
+ )
32
+ end
33
+
34
+ # SQLite has no array type or containment operators; tags live in a JSON
35
+ # array and json_each unpacks it into rows an EXISTS can probe. There is
36
+ # no index that can serve these predicates — SQLite tag queries are table
37
+ # scans, unless the model declares an index table for the column.
38
+ #
39
+ # json_each(NULL) yields no rows, so EXISTS over an untagged column is
40
+ # FALSE rather than NULL and the exclude path needs no NULL guard here.
41
+ def sqlite(model, column_name, tag_list)
42
+ if model.respond_to?(:metka_index_table) && (index_table = model.metka_index_table(column_name))
43
+ return sqlite_indexed(model, index_table, tag_list)
44
+ end
45
+
46
+ column = model.connection.quote_table_name("#{model.table_name}.#{column_name}")
47
+
48
+ sql =
49
+ if @match == :all
50
+ tag_list.to_a.map { |tag|
51
+ sanitize("EXISTS (SELECT 1 FROM json_each(#{column}) WHERE value = ?)", tag)
52
+ }.join(" AND ")
53
+ else
54
+ sanitize("EXISTS (SELECT 1 FROM json_each(#{column}) WHERE value IN (?))", tag_list.to_a)
55
+ end
56
+
57
+ # Grouping keeps the AND-joined predicates one node, so the query
58
+ # builder can OR and negate it like the PostgreSQL operator form.
59
+ Arel::Nodes::Grouping.new(Arel::Nodes::SqlLiteral.new(sql))
60
+ end
61
+
62
+ # The index strategy's side table: (tag_name, record_id) pairs whose
63
+ # WITHOUT ROWID primary key doubles as a covering index, maintained by
64
+ # the triggers `rails g metka:strategies:index` installs. "all" becomes
65
+ # an INTERSECT of per-tag index seeks, "any" a single IN probe. A row
66
+ # with a NULL tag column has no index entries, so the membership test is
67
+ # FALSE (never NULL) and the exclude path keeps working unchanged.
68
+ def sqlite_indexed(model, index_table, tag_list)
69
+ index = model.connection.quote_table_name(index_table)
70
+ id = model.connection.quote_table_name("#{model.table_name}.#{model.primary_key}")
71
+
72
+ sql =
73
+ if @match == :all
74
+ seeks = tag_list.to_a.map { |tag|
75
+ sanitize("SELECT record_id FROM #{index} WHERE tag_name = ?", tag)
76
+ }
77
+ "#{id} IN (#{seeks.join(" INTERSECT ")})"
78
+ else
79
+ sanitize("#{id} IN (SELECT record_id FROM #{index} WHERE tag_name IN (?))", tag_list.to_a)
80
+ end
81
+
82
+ Arel::Nodes::Grouping.new(Arel::Nodes::SqlLiteral.new(sql))
83
+ end
84
+
85
+ def sanitize(template, value)
86
+ ActiveRecord::Base.sanitize_sql_for_conditions([ template, value ])
87
+ end
88
+
89
+ def literal(template, value)
90
+ Arel::Nodes::SqlLiteral.new(sanitize(template, value))
91
+ end
92
+ end
93
+ end
data/lib/metka/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Metka
4
- VERSION = '2.3.3'
4
+ VERSION = "3.0.0"
5
5
  end
data/lib/metka.rb CHANGED
@@ -1,20 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'metka/version'
3
+ require "metka/version"
4
4
 
5
- require 'active_support/core_ext/module'
6
- require 'dry-configurable'
5
+ require "active_support/core_ext/module"
7
6
 
8
7
  module Metka
9
- require 'metka/tag_list'
10
- require 'metka/generic_parser'
11
- require 'metka/query_builder'
12
- require 'metka/model'
8
+ # How multiple tag columns combine in a tagged_with query. These are the
9
+ # public vocabulary, so they are plain symbols — Arel is an implementation
10
+ # detail of the query builder and does not belong in a caller's code.
11
+ AND = :and
12
+ OR = :or
13
+
14
+ require "metka/tag_list"
15
+ require "metka/generic_parser"
16
+ require "metka/query_builder"
17
+ require "metka/model"
13
18
 
14
19
  class Error < StandardError; end
15
20
 
16
- extend Dry::Configurable
21
+ mattr_accessor :parser, default: Metka::GenericParser
22
+ mattr_accessor :delimiter, default: ","
23
+
24
+ # These two settings came from dry-configurable, which reached them through
25
+ # Metka.config. Nothing else in the gem needed that gem, so it is gone and
26
+ # the entry points it provided forward to the module itself.
27
+ def self.config
28
+ self
29
+ end
17
30
 
18
- setting :parser, Metka::GenericParser
19
- setting :delimiter, default: ',', reader: true
31
+ def self.configure
32
+ yield self
33
+ end
20
34
  end
data/metka.gemspec CHANGED
@@ -7,35 +7,40 @@ require 'metka/version'
7
7
  Gem::Specification.new do |spec|
8
8
  spec.name = 'metka'
9
9
  spec.version = Metka::VERSION
10
- spec.authors = ['Igor Alexandrov', 'Andrey Morozov']
11
- spec.email = ['igor.alexandrov@gmail.com', 'andrey.morozov@jetrockets.ru']
10
+ spec.authors = [ 'Igor Alexandrov', 'Andrey Morozov' ]
11
+ spec.email = [ 'igor.alexandrov@gmail.com', 'andrey.morozov@jetrockets.ru' ]
12
12
 
13
13
  spec.summary = 'Rails tagging system based on PostgreSQL arrays'
14
14
  spec.description = 'Rails tagging system based on PostgreSQL arrays'
15
- spec.homepage = 'https://github.com/jetrockets/metka'
15
+ spec.homepage = 'https://github.com/metka-ruby/metka'
16
16
  spec.license = 'MIT'
17
17
 
18
+ spec.metadata = {
19
+ 'homepage_uri' => spec.homepage,
20
+ 'source_code_uri' => spec.homepage,
21
+ 'bug_tracker_uri' => "#{spec.homepage}/issues",
22
+ 'rubygems_mfa_required' => 'true'
23
+ }
24
+
18
25
  # Specify which files should be added to the gem when it is released.
19
26
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
21
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
- end
27
+ spec.files =
28
+ Dir.chdir(File.expand_path('..', __FILE__)) do
29
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
30
+ end
23
31
  spec.bindir = 'exe'
24
32
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
- spec.require_paths = ['lib']
33
+ spec.require_paths = [ 'lib' ]
26
34
 
27
- spec.add_dependency 'dry-configurable', '>= 0.8'
28
- spec.add_dependency 'rails', '>= 5.2'
35
+ # `setting :name, default: value` is only supported since 0.13
36
+ spec.add_dependency 'rails', '>= 7.1'
29
37
 
30
- spec.add_development_dependency 'ammeter', '>= 1.1'
31
38
  spec.add_development_dependency 'pry', '>= 0.12.2'
32
39
  spec.add_development_dependency 'bundler', '>= 1.3'
33
- spec.add_development_dependency 'faker', '>= 2.8'
40
+ spec.add_development_dependency 'minitest', '>= 5.15'
34
41
  spec.add_development_dependency 'pg', '>= 1.1'
42
+ spec.add_development_dependency 'sqlite3', '>= 2.1'
35
43
  spec.add_development_dependency 'rake', '>= 0.8.7'
36
- spec.add_development_dependency 'rspec', '>= 3.9'
37
- spec.add_development_dependency 'rspec-rails', '>= 3.9'
38
- spec.add_development_dependency 'timecop', '>= 0.9'
39
- spec.add_development_dependency 'database_cleaner', '>= 1.7'
40
- spec.required_ruby_version = '>= 2.5'
44
+ spec.add_development_dependency 'rubocop-rails-omakase', '>= 1.1'
45
+ spec.required_ruby_version = '>= 3.2'
41
46
  end