metka 1.0.3 → 2.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/.ruby-version +1 -0
- data/.travis.yml +2 -1
- data/Gemfile +1 -1
- data/Gemfile.lock +127 -112
- data/README.md +225 -40
- data/gemfiles/rails52.gemfile +2 -2
- data/gemfiles/rails6.gemfile +2 -2
- data/gemfiles/{rails5.gemfile → rails61.gemfile} +2 -2
- data/lib/generators/metka/strategies/materialized_view/materialized_view_generator.rb +21 -6
- data/lib/generators/metka/strategies/materialized_view/templates/migration.rb.erb +19 -12
- data/lib/generators/metka/strategies/view/templates/migration.rb.erb +11 -8
- data/lib/generators/metka/strategies/view/view_generator.rb +17 -5
- data/lib/metka.rb +1 -1
- data/lib/metka/generic_parser.rb +10 -6
- data/lib/metka/model.rb +64 -23
- data/lib/metka/query_builder.rb +57 -10
- data/lib/metka/query_builder/base_query.rb +1 -0
- data/lib/metka/version.rb +1 -1
- metadata +8 -9
- data/lib/metka/query_builder/exclude_all_tags_query.rb +0 -11
- data/lib/metka/query_builder/exclude_any_tags_query.rb +0 -11
data/gemfiles/rails52.gemfile
CHANGED
data/gemfiles/rails6.gemfile
CHANGED
@@ -9,10 +9,15 @@ module Metka
|
|
9
9
|
class MaterializedViewGenerator < ::Rails::Generators::Base # :nodoc:
|
10
10
|
include Rails::Generators::Migration
|
11
11
|
|
12
|
+
DEFAULT_SOURCE_COLUMNS = ['tags'].freeze
|
13
|
+
|
12
14
|
desc <<~LONGDESC
|
13
15
|
Generates migration to implement view strategy for Metka
|
14
16
|
|
15
|
-
> $ rails g metka:strategies:materialized_view
|
17
|
+
> $ rails g metka:strategies:materialized_view \
|
18
|
+
--source-table-name=NAME_OF_TABLE_WITH_TAGS \
|
19
|
+
--source-columns=NAME_OF_TAGGED_COLUMN_1 NAME_OF_TAGGED_COLUMN_2 \
|
20
|
+
--view-name=NAME_OF_VIEW
|
16
21
|
LONGDESC
|
17
22
|
|
18
23
|
source_root File.expand_path('templates', __dir__)
|
@@ -20,8 +25,11 @@ module Metka
|
|
20
25
|
class_option :source_table_name, type: :string, required: true,
|
21
26
|
desc: 'Name of the table that has a column with tags'
|
22
27
|
|
23
|
-
class_option :
|
24
|
-
|
28
|
+
class_option :source_columns, type: :array, default: DEFAULT_SOURCE_COLUMNS,
|
29
|
+
desc: 'List of the tagged columns names'
|
30
|
+
|
31
|
+
class_option :view_name, type: :string,
|
32
|
+
desc: 'Custom name for the resulting view'
|
25
33
|
|
26
34
|
def generate_migration
|
27
35
|
migration_template 'migration.rb.erb', "db/migrate/#{migration_name}.rb"
|
@@ -32,12 +40,19 @@ module Metka
|
|
32
40
|
options[:source_table_name]
|
33
41
|
end
|
34
42
|
|
35
|
-
def
|
36
|
-
options[:
|
43
|
+
def source_columns
|
44
|
+
options[:source_columns]
|
45
|
+
end
|
46
|
+
|
47
|
+
def source_columns_names
|
48
|
+
source_columns.join('_and_')
|
37
49
|
end
|
38
50
|
|
39
51
|
def view_name
|
40
|
-
|
52
|
+
return options[:view_name] if options[:view_name]
|
53
|
+
|
54
|
+
columns_sequence = source_columns == DEFAULT_SOURCE_COLUMNS ? nil : "_with_#{source_columns_names}"
|
55
|
+
"tagged#{columns_sequence}_#{source_table_name}"
|
41
56
|
end
|
42
57
|
|
43
58
|
def migration_name
|
@@ -5,15 +5,18 @@ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VE
|
|
5
5
|
execute <<-SQL
|
6
6
|
CREATE OR REPLACE FUNCTION metka_refresh_<%= view_name %>_materialized_view() RETURNS trigger LANGUAGE plpgsql AS $$
|
7
7
|
BEGIN
|
8
|
-
IF TG_OP = 'INSERT' AND
|
8
|
+
IF TG_OP = 'INSERT' AND
|
9
|
+
(<%= source_columns.map { |column| "NEW.#{column} IS NOT NULL" }.join(' OR ') %>) THEN
|
9
10
|
REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
|
10
11
|
END IF;
|
11
12
|
|
12
|
-
IF TG_OP = 'UPDATE' AND
|
13
|
+
IF TG_OP = 'UPDATE' AND
|
14
|
+
(<%= source_columns.map { |column| "OLD.#{column} IS DISTINCT FROM NEW.#{column}" }.join(' OR ') %>) THEN
|
13
15
|
REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
|
14
16
|
END IF;
|
15
17
|
|
16
|
-
IF TG_OP = 'DELETE' AND
|
18
|
+
IF TG_OP = 'DELETE' AND
|
19
|
+
(<%= source_columns.map { |column| "OLD.#{column} IS NOT NULL" }.join(' OR ') %>) THEN
|
17
20
|
REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
|
18
21
|
END IF;
|
19
22
|
RETURN NEW;
|
@@ -21,17 +24,21 @@ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VE
|
|
21
24
|
|
22
25
|
DROP MATERIALIZED VIEW IF EXISTS <%= view_name %>;
|
23
26
|
CREATE MATERIALIZED VIEW <%= view_name %> AS
|
24
|
-
SELECT
|
25
|
-
|
26
|
-
COUNT
|
27
|
-
FROM
|
28
|
-
|
27
|
+
SELECT
|
28
|
+
tag_name,
|
29
|
+
COUNT(*) AS taggings_count
|
30
|
+
FROM (
|
31
|
+
SELECT UNNEST
|
32
|
+
(<%= source_columns.join(' || ') %>) AS tag_name
|
33
|
+
FROM
|
34
|
+
<%= source_table_name %>
|
35
|
+
) subquery
|
29
36
|
GROUP BY
|
30
|
-
|
37
|
+
tag_name;
|
31
38
|
|
32
|
-
CREATE UNIQUE INDEX idx_<%= source_table_name %>_<%=
|
39
|
+
CREATE UNIQUE INDEX idx_<%= source_table_name %>_<%= source_columns_names %> ON <%= view_name %>(tag_name);
|
33
40
|
|
34
|
-
CREATE TRIGGER metka_on_<%= source_table_name %>
|
41
|
+
CREATE TRIGGER metka_on_<%= source_table_name %>_<%= source_columns_names %>
|
35
42
|
AFTER UPDATE OR INSERT OR DELETE ON <%= source_table_name %> FOR EACH ROW
|
36
43
|
EXECUTE PROCEDURE metka_refresh_<%= view_name %>_materialized_view();
|
37
44
|
SQL
|
@@ -39,7 +46,7 @@ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VE
|
|
39
46
|
|
40
47
|
def down
|
41
48
|
execute <<-SQL
|
42
|
-
DROP TRIGGER IF EXISTS metka_on_<%= source_table_name %> ON <%= source_table_name %>;
|
49
|
+
DROP TRIGGER IF EXISTS metka_on_<%= source_table_name %>_<%= source_columns_names %> ON <%= source_table_name %>;
|
43
50
|
DROP FUNCTION IF EXISTS metka_refresh_<%= view_name %>_materialized_view;
|
44
51
|
DROP MATERIALIZED VIEW IF EXISTS <%= view_name %>;
|
45
52
|
SQL
|
@@ -4,14 +4,17 @@ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VE
|
|
4
4
|
def up
|
5
5
|
execute <<-SQL
|
6
6
|
CREATE OR REPLACE VIEW <%= view_name %> AS
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
7
|
+
SELECT
|
8
|
+
tag_name,
|
9
|
+
COUNT(*) AS taggings_count
|
10
|
+
FROM (
|
11
|
+
SELECT UNNEST
|
12
|
+
(<%= source_columns.join(' || ') %>) AS tag_name
|
13
|
+
FROM
|
14
|
+
<%= source_table_name %>
|
15
|
+
) subquery
|
16
|
+
GROUP BY
|
17
|
+
tag_name;
|
15
18
|
SQL
|
16
19
|
end
|
17
20
|
|
@@ -9,6 +9,8 @@ module Metka
|
|
9
9
|
class ViewGenerator < ::Rails::Generators::Base # :nodoc:
|
10
10
|
include Rails::Generators::Migration
|
11
11
|
|
12
|
+
DEFAULT_SOURCE_COLUMNS = ['tags'].freeze
|
13
|
+
|
12
14
|
desc <<~LONGDESC
|
13
15
|
Generates migration to implement view strategy for Metka
|
14
16
|
|
@@ -20,8 +22,11 @@ module Metka
|
|
20
22
|
class_option :source_table_name, type: :string, required: true,
|
21
23
|
desc: 'Name of the table that has a column with tags'
|
22
24
|
|
23
|
-
class_option :
|
24
|
-
|
25
|
+
class_option :source_columns, type: :array, default: DEFAULT_SOURCE_COLUMNS,
|
26
|
+
desc: 'List of the tagged columns names'
|
27
|
+
|
28
|
+
class_option :view_name, type: :string,
|
29
|
+
desc: 'Custom name for the resulting view'
|
25
30
|
|
26
31
|
def generate_migration
|
27
32
|
migration_template 'migration.rb.erb', "db/migrate/#{migration_name}.rb"
|
@@ -32,12 +37,19 @@ module Metka
|
|
32
37
|
options[:source_table_name]
|
33
38
|
end
|
34
39
|
|
35
|
-
def
|
36
|
-
options[:
|
40
|
+
def source_columns
|
41
|
+
options[:source_columns]
|
42
|
+
end
|
43
|
+
|
44
|
+
def source_columns_names
|
45
|
+
source_columns.join('_and_')
|
37
46
|
end
|
38
47
|
|
39
48
|
def view_name
|
40
|
-
|
49
|
+
return options[:view_name] if options[:view_name]
|
50
|
+
|
51
|
+
columns_sequence = source_columns == DEFAULT_SOURCE_COLUMNS ? nil : "_with_#{source_columns_names}"
|
52
|
+
"tagged#{columns_sequence}_#{source_table_name}"
|
41
53
|
end
|
42
54
|
|
43
55
|
def migration_name
|
data/lib/metka.rb
CHANGED
data/lib/metka/generic_parser.rb
CHANGED
@@ -12,6 +12,11 @@ module Metka
|
|
12
12
|
class GenericParser
|
13
13
|
include Singleton
|
14
14
|
|
15
|
+
def initialize
|
16
|
+
@single_quote_pattern ||= {}
|
17
|
+
@double_quote_pattern ||= {}
|
18
|
+
end
|
19
|
+
|
15
20
|
def call(value)
|
16
21
|
TagList.new.tap do |tag_list|
|
17
22
|
case value
|
@@ -20,7 +25,7 @@ module Metka
|
|
20
25
|
gsub_quote_pattern!(tag_list, value, double_quote_pattern)
|
21
26
|
gsub_quote_pattern!(tag_list, value, single_quote_pattern)
|
22
27
|
|
23
|
-
tag_list.merge value.split(Regexp.new
|
28
|
+
tag_list.merge value.split(Regexp.new(delimiter)).map(&:strip).reject(&:empty?)
|
24
29
|
when Enumerable
|
25
30
|
tag_list.merge value.reject(&:empty?)
|
26
31
|
end
|
@@ -36,17 +41,16 @@ module Metka
|
|
36
41
|
}
|
37
42
|
end
|
38
43
|
|
39
|
-
def
|
40
|
-
|
41
|
-
delimeter.is_a?(Array) ? delimeter.join('|') : delimeter
|
44
|
+
def delimiter
|
45
|
+
Metka.delimiter
|
42
46
|
end
|
43
47
|
|
44
48
|
def single_quote_pattern
|
45
|
-
/(\A|#{
|
49
|
+
@single_quote_pattern[delimiter] ||= /(\A|#{delimiter})\s*'(.*?)'\s*(?=#{delimiter}\s*|\z)/
|
46
50
|
end
|
47
51
|
|
48
52
|
def double_quote_pattern
|
49
|
-
/(\A|#{
|
53
|
+
@double_quote_pattern[delimiter] ||= /(\A|#{delimiter})\s*"(.*?)"\s*(?=#{delimiter}\s*|\z)/
|
50
54
|
end
|
51
55
|
end
|
52
56
|
end
|
data/lib/metka/model.rb
CHANGED
@@ -1,47 +1,88 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'arel'
|
4
|
+
|
3
5
|
module Metka
|
4
|
-
|
5
|
-
|
6
|
+
OR = Arel::Nodes::Or
|
7
|
+
AND = Arel::Nodes::And
|
8
|
+
|
9
|
+
def self.Model(column: nil, columns: nil, **options)
|
10
|
+
columns = [column, *columns].uniq.compact
|
11
|
+
raise ArgumentError, 'Columns not specified' unless columns.present?
|
12
|
+
|
13
|
+
Metka::Model.new(columns: columns, **options)
|
6
14
|
end
|
7
15
|
|
8
16
|
class Model < Module
|
9
|
-
def initialize(
|
10
|
-
@
|
11
|
-
@options = options
|
17
|
+
def initialize(columns:, **options)
|
18
|
+
@columns = columns.dup.freeze
|
19
|
+
@options = options.dup.freeze
|
12
20
|
end
|
13
21
|
|
14
22
|
def included(base)
|
15
|
-
|
23
|
+
columns = @columns
|
16
24
|
parser = ->(tags) {
|
17
25
|
@options[:parser] ? @options[:parser].call(tags) : Metka.config.parser.instance.call(tags)
|
18
26
|
}
|
19
27
|
|
20
|
-
|
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)
|
21
36
|
parsed_tag_list = parser.call(tags)
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
end
|
37
|
+
|
38
|
+
return model.none if parsed_tag_list.empty?
|
39
|
+
|
40
|
+
request = ::Metka::QueryBuilder.new.call(model, cols, parsed_tag_list, options)
|
41
|
+
model.where(request)
|
28
42
|
}
|
29
43
|
|
30
44
|
base.class_eval do
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
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) }
|
50
|
+
end
|
51
|
+
|
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
|
57
|
+
|
58
|
+
tagged_with_lambda.call(self, tags, **options)
|
59
|
+
}
|
60
|
+
end
|
35
61
|
end
|
36
62
|
|
37
|
-
base.
|
38
|
-
|
39
|
-
|
63
|
+
base.define_singleton_method :metka_cloud do |*columns|
|
64
|
+
return [] if columns.blank?
|
65
|
+
|
66
|
+
prepared_unnest = columns.map { |column| "#{table_name}.#{column}" }.join(' || ')
|
67
|
+
subquery = all.select("UNNEST(#{prepared_unnest}) AS tag_name")
|
68
|
+
|
69
|
+
unscoped.from(subquery).group(:tag_name).pluck(:tag_name, Arel.sql('COUNT(*) AS taggings_count'))
|
40
70
|
end
|
41
71
|
|
42
|
-
|
43
|
-
|
72
|
+
columns.each do |column|
|
73
|
+
base.define_method(column.singularize + '_list=') do |v|
|
74
|
+
write_attribute(column, parser.call(v).to_a)
|
75
|
+
write_attribute(column, nil) if send(column).empty?
|
76
|
+
end
|
77
|
+
|
78
|
+
base.define_method(column.singularize + '_list') do
|
79
|
+
parser.call(send(column))
|
80
|
+
end
|
81
|
+
|
82
|
+
base.define_singleton_method :"#{column.singularize}_cloud" do
|
83
|
+
metka_cloud(column)
|
84
|
+
end
|
44
85
|
end
|
45
86
|
end
|
46
87
|
end
|
47
|
-
end
|
88
|
+
end
|
data/lib/metka/query_builder.rb
CHANGED
@@ -1,23 +1,70 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'arel'
|
3
4
|
require_relative 'query_builder/base_query'
|
4
|
-
require_relative 'query_builder/exclude_all_tags_query'
|
5
|
-
require_relative 'query_builder/exclude_any_tags_query'
|
6
5
|
require_relative 'query_builder/any_tags_query'
|
7
6
|
require_relative 'query_builder/all_tags_query'
|
8
7
|
|
9
8
|
module Metka
|
10
9
|
class QueryBuilder
|
11
|
-
def call(
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def options_to_strategy options
|
29
|
+
if options[:any].present?
|
30
|
+
AnyTagsQuery
|
18
31
|
else
|
19
|
-
AllTagsQuery
|
32
|
+
AllTagsQuery
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def join(operator, &block)
|
37
|
+
nodes = block.call
|
38
|
+
|
39
|
+
if operator == ::Metka::AND
|
40
|
+
join_and(nodes)
|
41
|
+
elsif operator == ::Metka::OR
|
42
|
+
join_or(nodes)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
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
|
50
|
+
|
51
|
+
case nodes
|
52
|
+
when node_base_klass
|
53
|
+
nodes
|
54
|
+
when Array
|
55
|
+
l, *r = nodes
|
56
|
+
return l if r.empty?
|
57
|
+
|
58
|
+
l.or(join_or(r))
|
20
59
|
end
|
21
60
|
end
|
61
|
+
|
62
|
+
def join_and(queries)
|
63
|
+
Arel::Nodes::And.new(queries)
|
64
|
+
end
|
65
|
+
|
66
|
+
def build_query(strategy, model, column, tags)
|
67
|
+
strategy.instance.call(model, column, tags)
|
68
|
+
end
|
22
69
|
end
|
23
70
|
end
|