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.
- checksums.yaml +7 -0
- data/.github/workflows/continuous-integration-workflow.yml +11 -0
- data/.gitignore +16 -0
- data/.rspec +2 -0
- data/.rubocop.yml +28 -0
- data/.travis.yml +36 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +199 -0
- data/LICENSE.txt +21 -0
- data/README.md +259 -0
- data/Rakefile +13 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/gemfiles/rails5.gemfile +6 -0
- data/gemfiles/rails52.gemfile +6 -0
- data/gemfiles/rails6.gemfile +6 -0
- data/lib/generators/metka/strategies/materialized_view/materialized_view_generator.rb +58 -0
- data/lib/generators/metka/strategies/materialized_view/templates/migration.rb.erb +47 -0
- data/lib/generators/metka/strategies/view/templates/migration.rb.erb +23 -0
- data/lib/generators/metka/strategies/view/view_generator.rb +58 -0
- data/lib/metka.rb +20 -0
- data/lib/metka/generic_parser.rb +52 -0
- data/lib/metka/model.rb +47 -0
- data/lib/metka/query_builder.rb +23 -0
- data/lib/metka/query_builder/all_tags_query.rb +11 -0
- data/lib/metka/query_builder/any_tags_query.rb +11 -0
- data/lib/metka/query_builder/base_query.rb +27 -0
- data/lib/metka/query_builder/exclude_all_tags_query.rb +11 -0
- data/lib/metka/query_builder/exclude_any_tags_query.rb +11 -0
- data/lib/metka/tag_list.rb +15 -0
- data/lib/metka/version.rb +5 -0
- data/metka.gemspec +41 -0
- metadata +260 -0
data/Rakefile
ADDED
|
@@ -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
|
data/bin/console
ADDED
|
@@ -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__)
|
data/bin/setup
ADDED
|
@@ -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
|
data/lib/metka.rb
ADDED
|
@@ -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
|
data/lib/metka/model.rb
ADDED
|
@@ -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,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
|