metka 0.1.2 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +4 -0
- data/.travis.yml +19 -8
- data/Gemfile.lock +62 -53
- data/README.md +338 -26
- data/bin/setup +1 -2
- data/gemfiles/rails5.gemfile +6 -0
- data/gemfiles/rails52.gemfile +6 -0
- data/gemfiles/rails6.gemfile +6 -0
- data/lib/generators/metka/strategies/materialized_view/materialized_view_generator.rb +22 -7
- data/lib/generators/metka/strategies/materialized_view/templates/migration.rb.erb +34 -20
- 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 +31 -1
- data/lib/metka/model.rb +74 -19
- data/lib/metka/query_builder.rb +55 -6
- data/lib/metka/query_builder/all_tags_query.rb +4 -19
- data/lib/metka/query_builder/any_tags_query.rb +4 -21
- data/lib/metka/query_builder/base_query.rb +27 -0
- data/lib/metka/version.rb +1 -1
- data/metka.gemspec +15 -12
- metadata +58 -25
- data/lib/metka/query_builder/exclude_tags_query.rb +0 -6
data/bin/setup
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,16 +40,23 @@ 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
|
44
|
-
"create_#{view_name}
|
59
|
+
"create_#{view_name}_materialized_view"
|
45
60
|
end
|
46
61
|
|
47
62
|
def migration_class_name
|
@@ -3,38 +3,52 @@
|
|
3
3
|
class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
|
4
4
|
def up
|
5
5
|
execute <<-SQL
|
6
|
-
CREATE
|
7
|
-
OR REPLACE FUNCTION metka_refresh_<%= view_name %>_materialized_view RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
6
|
+
CREATE OR REPLACE FUNCTION metka_refresh_<%= view_name %>_materialized_view() RETURNS trigger LANGUAGE plpgsql AS $$
|
8
7
|
BEGIN
|
9
|
-
IF TG_OP = 'INSERT' AND
|
8
|
+
IF TG_OP = 'INSERT' AND
|
9
|
+
(<%= source_columns.map { |column| "NEW.#{column} IS NOT NULL" }.join(' OR ') %>) THEN
|
10
10
|
REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
|
11
|
-
|
12
|
-
|
13
|
-
|
11
|
+
END IF;
|
12
|
+
|
13
|
+
IF TG_OP = 'UPDATE' AND
|
14
|
+
(<%= source_columns.map { |column| "OLD.#{column} IS DISTINCT FROM NEW.#{column}" }.join(' OR ') %>) THEN
|
14
15
|
REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
|
15
16
|
END IF;
|
16
17
|
|
17
|
-
|
18
|
-
|
18
|
+
IF TG_OP = 'DELETE' AND
|
19
|
+
(<%= source_columns.map { |column| "OLD.#{column} IS NOT NULL" }.join(' OR ') %>) THEN
|
20
|
+
REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
|
21
|
+
END IF;
|
22
|
+
RETURN NEW;
|
23
|
+
END $$;
|
19
24
|
|
20
|
-
|
25
|
+
DROP MATERIALIZED VIEW IF EXISTS <%= view_name %>;
|
26
|
+
CREATE MATERIALIZED VIEW <%= view_name %> AS
|
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
|
36
|
+
GROUP BY
|
37
|
+
tag_name;
|
21
38
|
|
22
|
-
|
23
|
-
( <%= source_column_name %> ) AS <%= source_column_name.singularize %>_name,
|
24
|
-
COUNT ( * ) AS taggings_count
|
25
|
-
FROM
|
26
|
-
<%= source_table_name %>
|
27
|
-
GROUP BY
|
28
|
-
<%= source_column_name.singularize %>_name;
|
39
|
+
CREATE UNIQUE INDEX idx_<%= source_table_name %>_<%= source_columns_names %> ON <%= view_name %>(tag_name);
|
29
40
|
|
30
|
-
CREATE TRIGGER metka_on_<%= source_table_name %>
|
31
|
-
|
32
|
-
EXECUTE PROCEDURE metka_refresh_<%= view_name %>_materialized_view();
|
41
|
+
CREATE TRIGGER metka_on_<%= source_table_name %>_<%= source_columns_names %>
|
42
|
+
AFTER UPDATE OR INSERT OR DELETE ON <%= source_table_name %> FOR EACH ROW
|
43
|
+
EXECUTE PROCEDURE metka_refresh_<%= view_name %>_materialized_view();
|
44
|
+
SQL
|
33
45
|
end
|
34
46
|
|
35
47
|
def down
|
36
48
|
execute <<-SQL
|
37
|
-
DROP
|
49
|
+
DROP TRIGGER IF EXISTS metka_on_<%= source_table_name %>_<%= source_columns_names %> ON <%= source_table_name %>;
|
50
|
+
DROP FUNCTION IF EXISTS metka_refresh_<%= view_name %>_materialized_view;
|
51
|
+
DROP MATERIALIZED VIEW IF EXISTS <%= view_name %>;
|
38
52
|
SQL
|
39
53
|
end
|
40
54
|
end
|
@@ -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,15 +12,45 @@ 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
|
18
23
|
when String
|
19
|
-
|
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)
|
27
|
+
|
28
|
+
tag_list.merge value.split(Regexp.new delimiter).map(&:strip).reject(&:empty?)
|
20
29
|
when Enumerable
|
21
30
|
tag_list.merge value.reject(&:empty?)
|
22
31
|
end
|
23
32
|
end
|
24
33
|
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def gsub_quote_pattern!(tag_list, value, pattern)
|
38
|
+
value.gsub!(pattern) {
|
39
|
+
tag_list.add(Regexp.last_match[2])
|
40
|
+
''
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
44
|
+
def delimiter
|
45
|
+
Metka.delimiter
|
46
|
+
end
|
47
|
+
|
48
|
+
def single_quote_pattern
|
49
|
+
@single_quote_pattern[delimiter] ||= /(\A|#{delimiter})\s*'(.*?)'\s*(?=#{delimiter}\s*|\z)/
|
50
|
+
end
|
51
|
+
|
52
|
+
def double_quote_pattern
|
53
|
+
@double_quote_pattern[delimiter] ||= /(\A|#{delimiter})\s*"(.*?)"\s*(?=#{delimiter}\s*|\z)/
|
54
|
+
end
|
25
55
|
end
|
26
56
|
end
|
data/lib/metka/model.rb
CHANGED
@@ -1,33 +1,88 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'arel'
|
4
4
|
|
5
5
|
module Metka
|
6
|
-
|
7
|
-
|
8
|
-
extend ActiveSupport::Concern
|
6
|
+
OR = Arel::Nodes::Or
|
7
|
+
AND = Arel::Nodes::And
|
9
8
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
options = options.dup
|
9
|
+
def self.Model(column: nil, columns: nil, **options)
|
10
|
+
columns = [column, *columns].uniq.compact
|
11
|
+
raise ArgumentError, 'Columns not specified' unless columns.present?
|
14
12
|
|
15
|
-
|
13
|
+
Metka::Model.new(columns: columns, **options)
|
14
|
+
end
|
16
15
|
|
17
|
-
|
18
|
-
|
16
|
+
class Model < Module
|
17
|
+
def initialize(columns:, **options)
|
18
|
+
@columns = columns.dup.freeze
|
19
|
+
@options = options.dup.freeze
|
19
20
|
end
|
20
21
|
|
21
|
-
def
|
22
|
-
|
23
|
-
|
24
|
-
|
22
|
+
def included(base)
|
23
|
+
columns = @columns
|
24
|
+
parser = ->(tags) {
|
25
|
+
@options[:parser] ? @options[:parser].call(tags) : Metka.config.parser.instance.call(tags)
|
26
|
+
}
|
25
27
|
|
26
|
-
|
27
|
-
|
28
|
-
|
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)
|
37
|
+
|
38
|
+
return model.none if parsed_tag_list.empty?
|
29
39
|
|
30
|
-
|
40
|
+
request = ::Metka::QueryBuilder.new.call(model, cols, parsed_tag_list, options)
|
41
|
+
model.where(request)
|
42
|
+
}
|
43
|
+
|
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) }
|
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
|
61
|
+
end
|
62
|
+
|
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, 'COUNT(*) AS taggings_count')
|
70
|
+
end
|
71
|
+
|
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
|
85
|
+
end
|
31
86
|
end
|
32
87
|
end
|
33
88
|
end
|
data/lib/metka/query_builder.rb
CHANGED
@@ -1,19 +1,68 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require 'arel'
|
4
|
+
require_relative 'query_builder/base_query'
|
4
5
|
require_relative 'query_builder/any_tags_query'
|
5
6
|
require_relative 'query_builder/all_tags_query'
|
6
7
|
|
7
8
|
module Metka
|
8
9
|
class QueryBuilder
|
9
|
-
def call(
|
10
|
+
def call(model, columns, tags, options)
|
11
|
+
strategy = options_to_strategy(options)
|
12
|
+
|
13
|
+
query = join(options[:join_operator]) do
|
14
|
+
columns.map do |column|
|
15
|
+
build_query(strategy, model, column, tags)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
10
19
|
if options[:exclude].present?
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
14
31
|
else
|
15
|
-
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::Node>, Arel::Node]
|
47
|
+
# @return [Arel::Node]
|
48
|
+
def join_or(nodes)
|
49
|
+
case nodes
|
50
|
+
when ::Arel::Node
|
51
|
+
nodes
|
52
|
+
when Array
|
53
|
+
l, *r = nodes
|
54
|
+
return l if r.empty?
|
55
|
+
|
56
|
+
l.or(join_or(r))
|
16
57
|
end
|
17
58
|
end
|
59
|
+
|
60
|
+
def join_and(queries)
|
61
|
+
Arel::Nodes::And.new(queries)
|
62
|
+
end
|
63
|
+
|
64
|
+
def build_query(strategy, model, column, tags)
|
65
|
+
strategy.instance.call(model, column, tags)
|
66
|
+
end
|
18
67
|
end
|
19
68
|
end
|