metka 1.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.
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ namespace :dummy do
9
+ require_relative 'spec/dummy/config/application'
10
+ Dummy::Application.load_tasks
11
+ end
12
+
13
+ task default: :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "metka"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+
5
+ gem install bundler --conservative
6
+ bundle check || bundle install
7
+
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: '..'
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Metka
7
+ module Generators
8
+ module Strategies
9
+ class MaterializedViewGenerator < ::Rails::Generators::Base # :nodoc:
10
+ include Rails::Generators::Migration
11
+
12
+ desc <<~LONGDESC
13
+ Generates migration to implement view strategy for Metka
14
+
15
+ > $ rails g metka:strategies:materialized_view --source-table-name=NAME_OF_TABLE_WITH_TAGS
16
+ LONGDESC
17
+
18
+ source_root File.expand_path('templates', __dir__)
19
+
20
+ class_option :source_table_name, type: :string, required: true,
21
+ desc: 'Name of the table that has a column with tags'
22
+
23
+ class_option :source_column_name, type: :string, default: 'tags',
24
+ desc: 'Name of the column with stored tags'
25
+
26
+ def generate_migration
27
+ migration_template 'migration.rb.erb', "db/migrate/#{migration_name}.rb"
28
+ end
29
+
30
+ no_tasks do
31
+ def source_table_name
32
+ options[:source_table_name]
33
+ end
34
+
35
+ def source_column_name
36
+ options[:source_column_name]
37
+ end
38
+
39
+ def view_name
40
+ "tagged_#{source_table_name}"
41
+ end
42
+
43
+ def migration_name
44
+ "create_#{view_name}_materialized_view"
45
+ end
46
+
47
+ def migration_class_name
48
+ migration_name.classify
49
+ end
50
+ end
51
+
52
+ def self.next_migration_number(dir)
53
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
4
+ def up
5
+ execute <<-SQL
6
+ CREATE OR REPLACE FUNCTION metka_refresh_<%= view_name %>_materialized_view() RETURNS trigger LANGUAGE plpgsql AS $$
7
+ BEGIN
8
+ IF TG_OP = 'INSERT' AND NEW.<%= source_column_name %> IS NOT NULL THEN
9
+ REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
10
+ END IF;
11
+
12
+ IF TG_OP = 'UPDATE' AND OLD.<%= source_column_name %> != NEW.<%= source_column_name %> THEN
13
+ REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
14
+ END IF;
15
+
16
+ IF TG_OP = 'DELETE' AND OLD.<%= source_column_name %> IS NOT NULL THEN
17
+ REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
18
+ END IF;
19
+ RETURN NEW;
20
+ END $$;
21
+
22
+ DROP MATERIALIZED VIEW IF EXISTS <%= view_name %>;
23
+ 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 %>
29
+ GROUP BY
30
+ <%= source_column_name.singularize %>_name;
31
+
32
+ CREATE UNIQUE INDEX idx_<%= source_table_name %>_<%= source_column_name %> ON <%= view_name %>(<%= source_column_name.singularize %>_name);
33
+
34
+ CREATE TRIGGER metka_on_<%= source_table_name %>
35
+ AFTER UPDATE OR INSERT OR DELETE ON <%= source_table_name %> FOR EACH ROW
36
+ EXECUTE PROCEDURE metka_refresh_<%= view_name %>_materialized_view();
37
+ SQL
38
+ end
39
+
40
+ def down
41
+ execute <<-SQL
42
+ DROP TRIGGER IF EXISTS metka_on_<%= source_table_name %> ON <%= source_table_name %>;
43
+ DROP FUNCTION IF EXISTS metka_refresh_<%= view_name %>_materialized_view;
44
+ DROP MATERIALIZED VIEW IF EXISTS <%= view_name %>;
45
+ SQL
46
+ end
47
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
4
+ def up
5
+ execute <<-SQL
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;
15
+ SQL
16
+ end
17
+
18
+ def down
19
+ execute <<-SQL
20
+ DROP VIEW <%= view_name %>;
21
+ SQL
22
+ end
23
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Metka
7
+ module Generators
8
+ module Strategies
9
+ class ViewGenerator < ::Rails::Generators::Base # :nodoc:
10
+ include Rails::Generators::Migration
11
+
12
+ desc <<~LONGDESC
13
+ Generates migration to implement view strategy for Metka
14
+
15
+ > $ rails g metka:strategies:view --source-table-name=NAME_OF_TABLE_WITH_TAGS
16
+ LONGDESC
17
+
18
+ source_root File.expand_path('templates', __dir__)
19
+
20
+ class_option :source_table_name, type: :string, required: true,
21
+ desc: 'Name of the table that has a column with tags'
22
+
23
+ class_option :source_column_name, type: :string, default: 'tags',
24
+ desc: 'Name of the column with stored tags'
25
+
26
+ def generate_migration
27
+ migration_template 'migration.rb.erb', "db/migrate/#{migration_name}.rb"
28
+ end
29
+
30
+ no_tasks do
31
+ def source_table_name
32
+ options[:source_table_name]
33
+ end
34
+
35
+ def source_column_name
36
+ options[:source_column_name]
37
+ end
38
+
39
+ def view_name
40
+ "tagged_#{source_table_name}"
41
+ end
42
+
43
+ def migration_name
44
+ "create_#{view_name}_view"
45
+ end
46
+
47
+ def migration_class_name
48
+ migration_name.classify
49
+ end
50
+ end
51
+
52
+ def self.next_migration_number(dir)
53
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'metka/version'
4
+
5
+ require 'active_support/core_ext/module'
6
+ require 'dry-configurable'
7
+
8
+ module Metka
9
+ require 'metka/tag_list'
10
+ require 'metka/generic_parser'
11
+ require 'metka/query_builder'
12
+ require 'metka/model'
13
+
14
+ class Error < StandardError; end
15
+
16
+ extend Dry::Configurable
17
+
18
+ setting :parser, Metka::GenericParser
19
+ setting :delimiter, ','
20
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module Metka
6
+ ##
7
+ # Returns a new Metka::TagList using the given tag string.
8
+ #
9
+ # Example:
10
+ # tag_list = Metka::GenericParser.instance.("One , Two, Three")
11
+ # tag_list # ["One", "Two", "Three"]
12
+ class GenericParser
13
+ include Singleton
14
+
15
+ def call(value)
16
+ TagList.new.tap do |tag_list|
17
+ case value
18
+ when String
19
+ value = value.to_s.dup
20
+ gsub_quote_pattern!(tag_list, value, double_quote_pattern)
21
+ gsub_quote_pattern!(tag_list, value, single_quote_pattern)
22
+
23
+ tag_list.merge value.split(Regexp.new joined_delimiter).map(&:strip).reject(&:empty?)
24
+ when Enumerable
25
+ tag_list.merge value.reject(&:empty?)
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def gsub_quote_pattern!(tag_list, value, pattern)
33
+ value.gsub!(pattern) {
34
+ tag_list.add(Regexp.last_match[2])
35
+ ''
36
+ }
37
+ end
38
+
39
+ def joined_delimiter
40
+ delimeter = Metka.config.delimiter
41
+ delimeter.is_a?(Array) ? delimeter.join('|') : delimeter
42
+ end
43
+
44
+ def single_quote_pattern
45
+ /(\A|#{joined_delimiter})\s*'(.*?)'\s*(?=#{joined_delimiter}\s*|\z)/
46
+ end
47
+
48
+ def double_quote_pattern
49
+ /(\A|#{joined_delimiter})\s*"(.*?)"\s*(?=#{joined_delimiter}\s*|\z)/
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metka
4
+ def self.Model(column:, **options)
5
+ Metka::Model.new(column: column, **options)
6
+ end
7
+
8
+ class Model < Module
9
+ def initialize(column: , **options)
10
+ @column = column
11
+ @options = options
12
+ end
13
+
14
+ def included(base)
15
+ column = @column
16
+ parser = ->(tags) {
17
+ @options[:parser] ? @options[:parser].call(tags) : Metka.config.parser.instance.call(tags)
18
+ }
19
+
20
+ search_by_tags = ->(model, tags, column, **options) {
21
+ 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
28
+ }
29
+
30
+ 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 }) }
35
+ end
36
+
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?
40
+ end
41
+
42
+ base.define_method(column.singularize + '_list') do
43
+ parser.call(self.send(column))
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ 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
+ require_relative 'query_builder/any_tags_query'
7
+ require_relative 'query_builder/all_tags_query'
8
+
9
+ module Metka
10
+ 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)
18
+ else
19
+ AllTagsQuery.instance.call(taggable_model, column, tag_list)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metka
4
+ class AllTagsQuery < BaseQuery
5
+ private
6
+
7
+ def infix_operator
8
+ '@>'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metka
4
+ class AnyTagsQuery < BaseQuery
5
+ private
6
+
7
+ def infix_operator
8
+ '&&'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ require 'singleton'
3
+
4
+ module Metka
5
+ class BaseQuery
6
+ include Singleton
7
+
8
+ def call(model, column_name, tag_list)
9
+ column_cast = Arel::Nodes::NamedFunction.new(
10
+ 'CAST',
11
+ [model.arel_table[column_name].as('text[]')]
12
+ )
13
+
14
+ value = Arel::Nodes::SqlLiteral.new(
15
+ # In Rails 5.2 and above Sanitanization moved to public level, but still we have to support 4.2 and 5.0 and 5.1
16
+ ActiveRecord::Base.send(:sanitize_sql_for_conditions, ['ARRAY[?]', tag_list.to_a])
17
+ )
18
+
19
+ value_cast = Arel::Nodes::NamedFunction.new(
20
+ 'CAST',
21
+ [value.as('text[]')]
22
+ )
23
+
24
+ Arel::Nodes::InfixOperation.new(infix_operator, column_cast, value_cast)
25
+ end
26
+ end
27
+ end