metka 1.0.3 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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