metka 0.1.2 → 2.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/.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
|