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
data/lib/metka/model.rb CHANGED
@@ -1,88 +1,167 @@
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)
54
+
55
+ columns = @columns.map(&:to_s)
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}"
67
+ end
43
68
 
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) }
69
+ # :on lands in raw SQL as identifiers, so it is checked against the
70
+ # declared columns rather than interpolated on trust — same contract
71
+ # as metka_cloud.
72
+ on = Array(options[:on] || columns).map(&:to_s)
73
+ unknown_columns = on - columns
74
+ if unknown_columns.any?
75
+ raise ArgumentError, "Unknown tag columns #{unknown_columns.inspect}, expected #{columns.inspect}"
50
76
  end
51
77
 
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
78
+ tag_list = parser.call(tags)
79
+ next self if tag_list.empty?
57
80
 
58
- tagged_with_lambda.call(self, tags, **options)
59
- }
60
- end
61
- end
81
+ where(::Metka::QueryBuilder.instance.call(self, on, tag_list, {
82
+ any: options.fetch(:any, false),
83
+ exclude: options[:exclude],
84
+ join_operator: options[:join_operator] || ::Metka::OR
85
+ }))
86
+ }
87
+ end
88
+
89
+ # The index strategy (`rails g metka:strategies:index`) maintains a
90
+ # (tag_name, record_id) side table per tagged column. Declaring it here
91
+ # via `index_tables: { "tags" => "posts_tags_index" }` lets the SQLite
92
+ # query path answer from that table instead of scanning json_each.
93
+ def define_index_tables(base)
94
+ tables = index_tables
62
95
 
63
- base.define_singleton_method :metka_cloud do |*columns|
64
- return [] if columns.blank?
96
+ base.define_singleton_method(:metka_index_table) { |column| tables[column.to_s] }
97
+ end
98
+
99
+ def define_tag_clouds(base)
100
+ taggable = @columns
101
+
102
+ # metka_cloud is public and its arguments land in raw SQL, so they are
103
+ # checked against the declared columns rather than interpolated on trust.
104
+ base.define_singleton_method :metka_cloud do |*cloud_columns|
105
+ return [] if cloud_columns.blank?
106
+
107
+ cloud_columns = cloud_columns.map(&:to_s)
108
+ unknown = cloud_columns - taggable
109
+ if unknown.any?
110
+ raise ArgumentError, "Unknown tag columns #{unknown.inspect}, expected #{taggable.inspect}"
111
+ end
65
112
 
66
- prepared_unnest = columns.map { |column| "#{table_name}.#{column}" }.join(' || ')
67
- subquery = all.select("UNNEST(#{prepared_unnest}) AS tag_name")
113
+ quoted = cloud_columns.map { |column|
114
+ connection.quote_table_name("#{table_name}.#{column}")
115
+ }
116
+
117
+ subquery =
118
+ if connection.adapter_name.match?(/sqlite/i)
119
+ # SQLite has no UNNEST; json_each unpacks each JSON array as a
120
+ # lateral cross join. Arrays cannot be concatenated the way
121
+ # PostgreSQL concatenates them, so multiple columns become one
122
+ # SELECT per column glued with UNION ALL — a NULL column joins to
123
+ # zero rows either way, matching UNNEST of a NULL array.
124
+ parts = quoted.map { |column|
125
+ all.select("json_each.value AS tag_name").joins("CROSS JOIN json_each(#{column})").to_sql
126
+ }
127
+ Arel.sql("(#{parts.join(" UNION ALL ")}) subquery")
128
+ else
129
+ all.select("UNNEST(#{quoted.join(" || ")}) AS tag_name")
130
+ end
131
+
132
+ unscoped.from(subquery).group(:tag_name).pluck(:tag_name, Arel.sql("COUNT(*) AS taggings_count"))
133
+ end
68
134
 
69
- unscoped.from(subquery).group(:tag_name).pluck(:tag_name, Arel.sql('COUNT(*) AS taggings_count'))
135
+ @columns.each do |column|
136
+ base.define_singleton_method(:"#{column.singularize}_cloud") { metka_cloud(column) }
70
137
  end
138
+ end
139
+
140
+ def define_tag_list_accessors(base)
141
+ parser = tag_parser
71
142
 
72
- columns.each do |column|
73
- base.define_method(column.singularize + '_list=') do |v|
74
- write_attribute(column, parser.call(v).to_a)
143
+ @columns.each do |column|
144
+ base.define_method(:"#{column.singularize}_list=") do |tags|
145
+ write_attribute(column, parser.call(tags).to_a)
75
146
  write_attribute(column, nil) if send(column).empty?
76
147
  end
77
148
 
78
- base.define_method(column.singularize + '_list') do
149
+ base.define_method(:"#{column.singularize}_list") do
79
150
  parser.call(send(column))
80
151
  end
81
-
82
- base.define_singleton_method :"#{column.singularize}_cloud" do
83
- metka_cloud(column)
84
- end
85
152
  end
86
153
  end
154
+
155
+ def index_tables
156
+ (@options[:index_tables] || {}).transform_keys(&:to_s).transform_values(&:to_s).freeze
157
+ end
158
+
159
+ # Metka.config.parser is looked up on every call so that reconfiguring it
160
+ # after a model has been included still takes effect.
161
+ def tag_parser
162
+ custom = @options[:parser]
163
+
164
+ ->(tags) { (custom || Metka.config.parser.instance).call(tags) }
165
+ end
87
166
  end
88
167
  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.4'
4
+ VERSION = "3.0.1"
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, default: Metka::GenericParser
19
- setting :delimiter, default: ',', reader: true
31
+ def self.configure
32
+ yield self
33
+ end
20
34
  end