metka 0.1.2 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/bin/setup CHANGED
@@ -5,5 +5,4 @@ set -e
5
5
  gem install bundler --conservative
6
6
  bundle check || bundle install
7
7
 
8
- RAILS_ENV=test bundle exec rake dummy:db:create
9
- RAILS_ENV=test bundle exec rake dummy:db:schema:load
8
+ RAILS_ENV=test bundle exec rake dummy:db:migrate:reset
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'rails', '~>5.1'
4
+ gem 'activerecord', '~> 5.1'
5
+
6
+ gemspec path: '..'
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'rails', '~>5.2'
4
+ gem 'activerecord', '~> 5.2'
5
+
6
+ gemspec path: '..'
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'rails', '~>6.0'
4
+ gem 'activerecord', '~> 6.0'
5
+
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,16 +40,23 @@ 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
44
- "create_#{view_name}_view"
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 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
10
10
  REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
11
- ELSIF TG_OP = 'UPDATE' AND OLD.<%= source_column_name %> IS NOT NULL AND NEW.<%= source_column_name %> IS NOT NULL THEN
12
- REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
13
- ELSIF TG_OP = 'DELETE' AND OLD.<%= source_column_name %> IS NOT NULL THEN
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
- RETURN NULL;
18
- END $$;
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
- CREATE OR REPLACE MATERIALZIED VIEW <%= view_name %> AS
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
- SELECT UNNEST
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
- BEFORE UPDATE OR INSERT OR DELETE ON <%= source_table_name %> FOR EACH ROW
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 VIEW <%= view_name %>;
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
- 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,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
- tag_list.merge value.split(',').map(&:strip).reject(&:empty?)
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
@@ -1,33 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/concern'
3
+ require 'arel'
4
4
 
5
5
  module Metka
6
- # Extends AR model with methods to use tags
7
- module Model
8
- extend ActiveSupport::Concern
6
+ OR = Arel::Nodes::Or
7
+ AND = Arel::Nodes::And
9
8
 
10
- included do
11
- scope :tagged_with, ->(tags, options = {}) do
12
- tag_list = Metka.config.parser.instance.call(tags)
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
- return none if tag_list.empty?
13
+ Metka::Model.new(columns: columns, **options)
14
+ end
16
15
 
17
- where(::Metka::QueryBuilder.new.call(self, 'tags', tag_list, options))
18
- end
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 tag_list=(v)
22
- self.tags = Metka.config.parser.instance.call(v).to_a
23
- self.tags = nil if tags.empty?
24
- end
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
- def tag_list
27
- Metka.config.parser.instance.call(tags)
28
- end
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
- module ClassMethods # :nodoc:
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
@@ -1,19 +1,68 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'query_builder/exclude_tags_query'
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(taggable_model, column, tag_list, options)
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
- ExcludeTagsQuery.new(taggable_model, tag_model, tagging_model, tag_list, options).build
12
- elsif options[:any].present?
13
- AnyTagsQuery.instance.call(taggable_model, column, tag_list)
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.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::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