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.
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