metka 1.0.3 → 2.1.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.
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem 'rails', '~>5.2'
4
- gem 'activerecord', '~> 5.2'
3
+ gem 'rails', '~> 5.2'
4
+ gem 'activerecord', '~> 5.2.4.3'
5
5
 
6
6
  gemspec path: '..'
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem 'rails', '~>6.0'
4
- gem 'activerecord', '~> 6.0'
3
+ gem 'rails', '~> 6.0', ' < 6.1'
4
+ gem 'activerecord', '~> 6.0', '< 6.1'
5
5
 
6
6
  gemspec path: '..'
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem 'rails', '~>5.1'
4
- gem 'activerecord', '~> 5.1'
3
+ gem 'rails', '~> 6.1'
4
+ gem 'activerecord', '~> 6.1'
5
5
 
6
6
  gemspec path: '..'
@@ -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 --source-table-name=NAME_OF_TABLE_WITH_TAGS
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 :source_column_name, type: :string, default: 'tags',
24
- desc: 'Name of the column with stored tags'
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 source_column_name
36
- options[:source_column_name]
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
- "tagged_#{source_table_name}"
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 NEW.<%= source_column_name %> IS NOT NULL THEN
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 OLD.<%= source_column_name %> != NEW.<%= source_column_name %> THEN
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 OLD.<%= source_column_name %> IS NOT NULL THEN
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 UNNEST
25
- ( <%= source_column_name %> ) AS <%= source_column_name.singularize %>_name,
26
- COUNT ( * ) AS taggings_count
27
- FROM
28
- <%= source_table_name %>
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
- <%= source_column_name.singularize %>_name;
37
+ tag_name;
31
38
 
32
- CREATE UNIQUE INDEX idx_<%= source_table_name %>_<%= source_column_name %> ON <%= view_name %>(<%= source_column_name.singularize %>_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
- SELECT UNNEST
9
- ( <%= source_column_name %> ) AS <%= source_column_name.singularize %>_name,
10
- COUNT ( * ) AS taggings_count
11
- FROM
12
- <%= source_table_name %>
13
- GROUP BY
14
- <%= source_column_name.singularize %>_name;
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 :source_column_name, type: :string, default: 'tags',
24
- desc: 'Name of the column with stored tags'
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 source_column_name
36
- options[:source_column_name]
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
- "tagged_#{source_table_name}"
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
@@ -16,5 +16,5 @@ module Metka
16
16
  extend Dry::Configurable
17
17
 
18
18
  setting :parser, Metka::GenericParser
19
- setting :delimiter, ','
19
+ setting :delimiter, ',', reader: true
20
20
  end
@@ -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 joined_delimiter).map(&:strip).reject(&:empty?)
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 joined_delimiter
40
- delimeter = Metka.config.delimiter
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|#{joined_delimiter})\s*'(.*?)'\s*(?=#{joined_delimiter}\s*|\z)/
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|#{joined_delimiter})\s*"(.*?)"\s*(?=#{joined_delimiter}\s*|\z)/
53
+ @double_quote_pattern[delimiter] ||= /(\A|#{delimiter})\s*"(.*?)"\s*(?=#{delimiter}\s*|\z)/
50
54
  end
51
55
  end
52
56
  end
@@ -1,47 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'arel'
4
+
3
5
  module Metka
4
- def self.Model(column:, **options)
5
- Metka::Model.new(column: column, **options)
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(column: , **options)
10
- @column = column
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
- column = @column
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
- search_by_tags = ->(model, tags, column, **options) {
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
- if options[:without].present?
23
- model.where.not(::Metka::QueryBuilder.new.call(model, column, parsed_tag_list, options))
24
- else
25
- return model.none if parsed_tag_list.empty?
26
- model.where(::Metka::QueryBuilder.new.call(model, column, parsed_tag_list, options))
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
- scope "with_all_#{column}", ->(tags) { search_by_tags.call(self, tags, column) }
32
- scope "with_any_#{column}", ->(tags) { search_by_tags.call(self, tags, column, { any: true }) }
33
- scope "without_all_#{column}", ->(tags) { search_by_tags.call(self, tags, column, { exclude_all: true, without: true }) }
34
- scope "without_any_#{column}", ->(tags) { search_by_tags.call(self, tags, column, { exclude_any: true, without: true }) }
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.define_method(column.singularize + '_list=') do |v|
38
- self.write_attribute(column, parser.call(v).to_a)
39
- self.write_attribute(column, nil) if self.send(column).empty?
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
- base.define_method(column.singularize + '_list') do
43
- parser.call(self.send(column))
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
@@ -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(taggable_model, column, tag_list, options)
12
- if options[:exclude_all].present?
13
- ExcludeAllTagsQuery.instance.call(taggable_model, column, tag_list)
14
- elsif options[:exclude_any].present?
15
- ExcludeAnyTagsQuery.instance.call(taggable_model, column, tag_list)
16
- elsif options[:any].present?
17
- AnyTagsQuery.instance.call(taggable_model, column, tag_list)
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.instance.call(taggable_model, column, tag_list)
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