metka 2.3.4 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/lint_code.yml +9 -6
- data/.github/workflows/lint_docs.yml +15 -16
- data/.github/workflows/release.yml +23 -0
- data/.github/workflows/tests.yml +97 -0
- data/.gitignore +16 -7
- data/.rubocop.yml +8 -16
- data/.ruby-version +1 -1
- data/Gemfile +1 -1
- data/README.md +273 -236
- data/Rakefile +9 -4
- data/assets/metka-icon.svg +23 -0
- data/assets/metka-logo.svg +28 -0
- data/benchmark/Gemfile +17 -0
- data/benchmark/README.md +170 -0
- data/benchmark/benchmark.rb +718 -0
- data/benchmark/results.sqlite.txt +131 -0
- data/benchmark/results.txt +150 -0
- data/bin/setup +7 -0
- data/docs/superpowers/plans/2026-08-18-cloud-table-naming.md +316 -0
- data/docs/superpowers/specs/2026-08-18-cloud-table-naming-design.md +105 -0
- data/forspell.dict +3 -1
- data/gemfiles/rails71.gemfile +6 -0
- data/gemfiles/rails72.gemfile +6 -0
- data/gemfiles/rails80.gemfile +6 -0
- data/gemfiles/rails81.gemfile +6 -0
- data/gemfiles/rubocop.gemfile +2 -2
- data/lib/generators/metka/strategies/index/index_generator.rb +77 -0
- data/lib/generators/metka/strategies/index/templates/migration.rb.erb +89 -0
- data/lib/generators/metka/strategies/table/table_generator.rb +83 -0
- data/lib/generators/metka/strategies/table/templates/migration.rb.erb +128 -0
- data/lib/generators/metka/strategies/table/templates/migration.sqlite.rb.erb +93 -0
- data/lib/metka/generic_parser.rb +28 -14
- data/lib/metka/model.rb +121 -51
- data/lib/metka/query_builder.rb +45 -49
- data/lib/metka/tag_list.rb +16 -8
- data/lib/metka/tags_query.rb +93 -0
- data/lib/metka/version.rb +1 -1
- data/lib/metka.rb +24 -10
- data/metka.gemspec +21 -17
- metadata +43 -104
- data/.github/workflows/specs.yml +0 -86
- data/.rspec +0 -2
- data/Gemfile.lock +0 -239
- data/gemfiles/rails52.gemfile +0 -6
- data/gemfiles/rails6.gemfile +0 -6
- data/gemfiles/rails61.gemfile +0 -6
- data/lib/generators/metka/strategies/materialized_view/materialized_view_generator.rb +0 -73
- data/lib/generators/metka/strategies/materialized_view/templates/migration.rb.erb +0 -54
- data/lib/generators/metka/strategies/view/templates/migration.rb.erb +0 -26
- data/lib/generators/metka/strategies/view/view_generator.rb +0 -70
- data/lib/metka/query_builder/all_tags_query.rb +0 -11
- data/lib/metka/query_builder/any_tags_query.rb +0 -11
- data/lib/metka/query_builder/base_query.rb +0 -48
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
|
data/lib/metka/model.rb
CHANGED
|
@@ -1,88 +1,158 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
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,
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
87
|
+
base.define_singleton_method(:metka_index_table) { |column| tables[column.to_s] }
|
|
88
|
+
end
|
|
68
89
|
|
|
69
|
-
|
|
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.
|
|
74
|
-
|
|
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
|
|
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
|
data/lib/metka/query_builder.rb
CHANGED
|
@@ -1,70 +1,66 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require_relative
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
13
|
+
STRATEGIES = {
|
|
14
|
+
all: TagsQuery.new(match: :all).freeze,
|
|
15
|
+
any: TagsQuery.new(match: :any).freeze
|
|
16
|
+
}.freeze
|
|
27
17
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
63
|
-
|
|
49
|
+
def normalize(operator)
|
|
50
|
+
LEGACY_OPERATORS.fetch(operator, operator)
|
|
64
51
|
end
|
|
65
52
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
data/lib/metka/tag_list.rb
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
data/lib/metka.rb
CHANGED
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "metka/version"
|
|
4
4
|
|
|
5
|
-
require
|
|
6
|
-
require 'dry-configurable'
|
|
5
|
+
require "active_support/core_ext/module"
|
|
7
6
|
|
|
8
7
|
module Metka
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
31
|
+
def self.configure
|
|
32
|
+
yield self
|
|
33
|
+
end
|
|
20
34
|
end
|
data/metka.gemspec
CHANGED
|
@@ -7,36 +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/
|
|
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 =
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
spec.add_dependency 'rails', '>=
|
|
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 '
|
|
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 '
|
|
37
|
-
spec.
|
|
38
|
-
spec.add_development_dependency 'timecop', '>= 0.9'
|
|
39
|
-
spec.add_development_dependency 'database_cleaner', '>= 1.7'
|
|
40
|
-
spec.add_development_dependency 'jetrockets-standard', '>= 1.1'
|
|
41
|
-
spec.required_ruby_version = '>= 2.5'
|
|
44
|
+
spec.add_development_dependency 'rubocop-rails-omakase', '>= 1.1'
|
|
45
|
+
spec.required_ruby_version = '>= 3.2'
|
|
42
46
|
end
|