no_fly_list 0.1.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/lib/generators/no_fly_list/install_generator.rb +52 -0
- data/lib/generators/no_fly_list/models_generator.rb +54 -0
- data/lib/generators/no_fly_list/tagging_generator.rb +67 -0
- data/lib/generators/no_fly_list/templates/application_tag.rb.erb +3 -0
- data/lib/generators/no_fly_list/templates/application_tagging.rb.erb +3 -0
- data/lib/generators/no_fly_list/templates/create_application_tagging_table.rb.erb +15 -0
- data/lib/generators/no_fly_list/templates/create_tagging_table.rb.erb +20 -0
- data/lib/generators/no_fly_list/templates/tag_model.rb.erb +9 -0
- data/lib/generators/no_fly_list/templates/tag_parser.rb +23 -0
- data/lib/generators/no_fly_list/templates/tagging_model.rb.erb +9 -0
- data/lib/generators/no_fly_list/transformer_generator.rb +19 -0
- data/lib/no_fly_list/application_tag.rb +23 -0
- data/lib/no_fly_list/application_tagging.rb +22 -0
- data/lib/no_fly_list/railtie.rb +18 -0
- data/lib/no_fly_list/railties/tasks.rake +94 -0
- data/lib/no_fly_list/tag_record.rb +21 -0
- data/lib/no_fly_list/taggable_record/configuration.rb +183 -0
- data/lib/no_fly_list/taggable_record/mutation.rb +43 -0
- data/lib/no_fly_list/taggable_record/query.rb +100 -0
- data/lib/no_fly_list/taggable_record.rb +28 -0
- data/lib/no_fly_list/tagging_proxy.rb +221 -0
- data/lib/no_fly_list/tagging_record.rb +21 -0
- data/lib/no_fly_list/test_helper.rb +15 -0
- data/lib/no_fly_list/version.rb +5 -0
- data/lib/no_fly_list.rb +28 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0fbe92a4791b7cf24e4a2ca876b1c7f62cd5a37d653cdcad45f752a393c04f16
|
|
4
|
+
data.tar.gz: 00c74e1f82fea31e30f517ff102d5fc41c714392c73b05c841737837b521b370
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 590cf033d9ed9fa653341b8aedb2e731244e45505b0945147e7da1ca0fac264e7f161cd5bb5608e95c5567f17fdba5837338e851e1dc76d707b8bfef379974ce
|
|
7
|
+
data.tar.gz: b55be12266e3e9ee2deeb8fa82aa4e405762ad8a2f3bb6643933f30dab10a918b5add73ffb7a29b17f741be404afc952027e53e85c4253f8deee4d6630b3966b
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
require 'rails/generators/named_base'
|
|
6
|
+
|
|
7
|
+
# Usage:
|
|
8
|
+
# bin/rails generate no_fly_list:application_tag
|
|
9
|
+
|
|
10
|
+
module NoFlyList
|
|
11
|
+
module Generators
|
|
12
|
+
class InstallGenerator < Rails::Generators::Base
|
|
13
|
+
include Rails::Generators::Migration
|
|
14
|
+
source_root File.expand_path('templates', __dir__)
|
|
15
|
+
|
|
16
|
+
argument :connection_name, type: :string, desc: 'The name of the database connection', default: 'primary'
|
|
17
|
+
|
|
18
|
+
def copy_application_tag
|
|
19
|
+
ensure_connection_exists
|
|
20
|
+
template 'application_tag.rb.erb', File.join('app/models', 'application_tag.rb')
|
|
21
|
+
template 'application_tagging.rb.erb', File.join('app/models', 'application_tagging.rb')
|
|
22
|
+
migration_template 'create_application_tagging_table.rb.erb', 'db/migrate/create_application_tagging_table.rb'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.next_migration_number(dirname)
|
|
26
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def ensure_connection_exists
|
|
32
|
+
connection_db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).find do |config|
|
|
33
|
+
config.name == connection_name
|
|
34
|
+
end
|
|
35
|
+
return if connection_db_config
|
|
36
|
+
|
|
37
|
+
say "Connection '#{connection_name}' does not exist. Please provide a valid connection name."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def connection_abstract_class_name
|
|
41
|
+
# should be abstract class name
|
|
42
|
+
ActiveRecord::Base.descendants.find do |klass|
|
|
43
|
+
klass.abstract_class? && klass.connection_db_config.name == connection_name
|
|
44
|
+
end.name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def migration_version
|
|
48
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
require 'rails/generators'
|
|
5
|
+
require 'rails/generators/active_record'
|
|
6
|
+
require 'rails/generators/named_base'
|
|
7
|
+
|
|
8
|
+
module NoFlyList
|
|
9
|
+
module Generators
|
|
10
|
+
class ModelsGenerator < Rails::Generators::NamedBase
|
|
11
|
+
source_root File.expand_path('templates', __dir__)
|
|
12
|
+
|
|
13
|
+
def create_model_files
|
|
14
|
+
return unless validate_model # Ensure it's an ActiveRecord model
|
|
15
|
+
|
|
16
|
+
template 'tagging_model.rb.erb', File.join('app/models', class_path, "#{file_name.underscore}/tagging.rb")
|
|
17
|
+
template 'tag_model.rb.erb', File.join('app/models', class_path, "#{file_name.underscore}_tag.rb")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def model_class
|
|
23
|
+
@model_class ||= class_name.constantize
|
|
24
|
+
end
|
|
25
|
+
alias taggable_klass class_name
|
|
26
|
+
|
|
27
|
+
def tag_class_name
|
|
28
|
+
"#{class_name}Tag"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def tagging_class_name
|
|
32
|
+
"#{class_name}::Tagging"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_model
|
|
36
|
+
if model_class < ActiveRecord::Base
|
|
37
|
+
true
|
|
38
|
+
else
|
|
39
|
+
say "#{class_name} is not an ActiveRecord model. Aborting generator.", :red
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
rescue NameError
|
|
43
|
+
say "#{class_name} is not a valid constant. Aborting generator.", :red
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def model_abstract_class_name
|
|
48
|
+
model_class.ancestors.find do |klass|
|
|
49
|
+
klass.is_a?(Class) && klass.abstract_class?
|
|
50
|
+
end.name
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
require 'rails/generators'
|
|
5
|
+
require 'rails/generators/active_record'
|
|
6
|
+
require 'rails/generators/named_base'
|
|
7
|
+
|
|
8
|
+
module NoFlyList
|
|
9
|
+
module Generators
|
|
10
|
+
class TaggingGenerator < Rails::Generators::NamedBase
|
|
11
|
+
include ActiveRecord::Generators::Migration
|
|
12
|
+
|
|
13
|
+
class_option :database, type: :string, default: 'primary',
|
|
14
|
+
desc: 'Use different database for migration'
|
|
15
|
+
|
|
16
|
+
def self.default_generator_root
|
|
17
|
+
File.dirname(__FILE__)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create_migration_file
|
|
21
|
+
ensure_model_exists
|
|
22
|
+
migration_template 'create_tagging_table.rb.erb',
|
|
23
|
+
[db_migrate_path, "create_#{migration_name}.rb"].compact.join('/')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.next_migration_number(dirname)
|
|
27
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def ensure_model_exists
|
|
33
|
+
name.constantize
|
|
34
|
+
rescue NameError
|
|
35
|
+
raise ArgumentError, "Model '#{name}' does not exist. Please provide a valid model name."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def migration_name
|
|
39
|
+
"tagging_#{name.underscore.tr('/', '_')}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def migration_class_name
|
|
43
|
+
"CreateTagging#{name.gsub('::', '')}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def target_class
|
|
47
|
+
@target_class ||= name.constantize
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def model_table_name
|
|
51
|
+
target_class.table_name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tag_table_name
|
|
55
|
+
"#{model_table_name.singularize}_tags"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def tagging_table_name
|
|
59
|
+
"#{model_table_name.singularize}_taggings"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def migration_version
|
|
63
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
create_table :application_tags do |t|
|
|
4
|
+
t.string :name
|
|
5
|
+
t.timestamps
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
create_table :application_taggings do |t|
|
|
9
|
+
t.bigint :tag_id, null: false
|
|
10
|
+
t.foreign_key :application_tags, column: :tag_id
|
|
11
|
+
t.references :taggable, polymorphic: true, null: false
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :<%= tag_table_name %>, id: :bigint do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.timestamps default: -> { 'CURRENT_TIMESTAMP' }
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
create_table :<%= tagging_table_name %> do |t|
|
|
9
|
+
t.column :taggable_id, :bigint, null: false, index: true # Change to :uuid if you are using UUIDs
|
|
10
|
+
t.column :tag_id, :bigint, null: false, index: true
|
|
11
|
+
t.string :context, null: false
|
|
12
|
+
t.timestamps default: -> { 'CURRENT_TIMESTAMP' }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :<%= tag_table_name %>, :name, unique: true
|
|
16
|
+
add_index :<%= tagging_table_name %>, %i[taggable_id tag_id], unique: true
|
|
17
|
+
add_foreign_key :<%= tagging_table_name %>, :<%= tag_table_name %>, column: :tag_id
|
|
18
|
+
add_foreign_key :<%= tagging_table_name %>, :<%= model_table_name %>, column: :taggable_id
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% module_namespacing do -%>
|
|
2
|
+
class <%= taggable_klass %>Tag < <%= model_abstract_class_name %>
|
|
3
|
+
self.table_name = '<%="#{model_class.table_name.singularize}_tags" %>'
|
|
4
|
+
|
|
5
|
+
has_many :taggings, class_name: '<%= tagging_class_name %>', dependent: :destroy
|
|
6
|
+
has_many :taggables, through: :taggings, source: :taggable, source_type: '<%= taggable_klass %>'
|
|
7
|
+
include NoFlyList::TagRecord
|
|
8
|
+
end
|
|
9
|
+
<% end -%>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApplicationTagTransformer
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
# @param tags [String|Array<String>]
|
|
7
|
+
def parse_tags(tags)
|
|
8
|
+
tags = recreate_string(tags) if tags.is_a?(Array)
|
|
9
|
+
tags.split(separator).map(&:strip)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Recreate a string from an array of tags
|
|
13
|
+
# @param tags [Array<String>]
|
|
14
|
+
# @return [String]
|
|
15
|
+
def recreate_string(tags)
|
|
16
|
+
tags.join(separator)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [String]
|
|
20
|
+
def separator
|
|
21
|
+
','
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% module_namespacing do -%>
|
|
2
|
+
class <%= taggable_klass %>::Tagging < <%= model_abstract_class_name %>
|
|
3
|
+
self.table_name = '<%="#{model_class.table_name.singularize}_taggings" %>'
|
|
4
|
+
|
|
5
|
+
belongs_to :taggable, class_name: '<%= model_class.name %>', foreign_key: 'taggable_id'
|
|
6
|
+
belongs_to :tag, class_name: '<%= tag_class_name %>'
|
|
7
|
+
include NoFlyList::TaggingRecord
|
|
8
|
+
end
|
|
9
|
+
<% end -%>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
require 'rails/generators'
|
|
5
|
+
require 'rails/generators/active_record'
|
|
6
|
+
require 'rails/generators/named_base'
|
|
7
|
+
|
|
8
|
+
unless defined?(ApplicationTagTransformer)
|
|
9
|
+
module NoFlyList
|
|
10
|
+
module Generators
|
|
11
|
+
class TransformerGenerator < Rails::Generators::Base
|
|
12
|
+
source_root File.expand_path('templates', __dir__)
|
|
13
|
+
def create_tag_transformer_file
|
|
14
|
+
template 'tag_transformer.rb', File.join('app/transformers', 'application_tag_transformer.rb')
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NoFlyList
|
|
4
|
+
# This module provides functionality for global tags.
|
|
5
|
+
# Only one instance of this tag is allowed per database/schema.
|
|
6
|
+
#
|
|
7
|
+
# This concern can be included in models that represent global tags to ensure uniqueness across the database/schema.
|
|
8
|
+
#
|
|
9
|
+
# @example Usage
|
|
10
|
+
# class ApplicationTag < ApplicationRecord
|
|
11
|
+
# include NoFlyList::ApplicationTag
|
|
12
|
+
# end
|
|
13
|
+
module ApplicationTag
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
self.table_name = Rails.configuration.no_fly_list.application_tag_table_name || 'application_tags'
|
|
18
|
+
|
|
19
|
+
has_many :taggings, class_name: 'ApplicationTagging', dependent: :destroy, foreign_key: 'tag_id'
|
|
20
|
+
has_many :taggables, through: :taggings, source: :taggable
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NoFlyList
|
|
4
|
+
# This module provides functionality for global tags.
|
|
5
|
+
# Only one instance of this tag is allowed per database/schema.
|
|
6
|
+
#
|
|
7
|
+
# This concern can be included in models that represent global tags to ensure uniqueness across the database/schema.
|
|
8
|
+
#
|
|
9
|
+
# @example Usage
|
|
10
|
+
# class ApplicationTagging < ApplicationRecord
|
|
11
|
+
# include NoFlyList::ApplicationTagging
|
|
12
|
+
# end
|
|
13
|
+
module ApplicationTagging
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
self.table_name = Rails.configuration.no_fly_list.application_tagging_table_name || 'application_taggings'
|
|
18
|
+
belongs_to :tag, class_name: 'ApplicationTag', foreign_key: 'tag_id'
|
|
19
|
+
belongs_to :taggable, polymorphic: true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record/railtie'
|
|
4
|
+
require 'rails'
|
|
5
|
+
|
|
6
|
+
module NoFlyList
|
|
7
|
+
class Railtie < Rails::Railtie # :nodoc:
|
|
8
|
+
config.no_fly_list = ActiveSupport::OrderedOptions.new
|
|
9
|
+
config.no_fly_list.tag_class_name = 'ApplicationTag'
|
|
10
|
+
config.no_fly_list.tag_table_name = 'application_tags'
|
|
11
|
+
config.no_fly_list.tagging_class_name = 'ApplicationTagging'
|
|
12
|
+
config.no_fly_list.tagging_table_name = 'application_taggings'
|
|
13
|
+
|
|
14
|
+
rake_tasks do
|
|
15
|
+
load 'no_fly_list/railties/tasks.rake'
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :no_fly_list do
|
|
4
|
+
desc 'List all taggable records'
|
|
5
|
+
task taggable_records: :environment do
|
|
6
|
+
Rails.application.eager_load!
|
|
7
|
+
taggable_classes = ActiveRecord::Base.descendants.select do |klass|
|
|
8
|
+
klass.included_modules.any? { |mod| mod.in?([NoFlyList::TaggableRecord]) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
puts "Found #{taggable_classes.size} taggable classes:\n\n"
|
|
12
|
+
|
|
13
|
+
taggable_classes.each do |klass|
|
|
14
|
+
puts "Class: #{klass.name}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc 'List all tag records'
|
|
19
|
+
task tag_records: :environment do
|
|
20
|
+
Rails.application.eager_load!
|
|
21
|
+
tag_classes = ActiveRecord::Base.descendants.select do |klass|
|
|
22
|
+
klass.included_modules.any? { |mod| mod.in?([NoFlyList::ApplicationTag, NoFlyList::TagRecord]) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
puts "Found #{tag_classes.size} tag classes:\n\n"
|
|
26
|
+
|
|
27
|
+
tag_classes.each do |klass|
|
|
28
|
+
puts "Class: #{klass.name}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc 'Check taggable records and their associated tables'
|
|
33
|
+
task check_taggable_records: :environment do
|
|
34
|
+
Rails.application.eager_load!
|
|
35
|
+
taggable_classes = ActiveRecord::Base.descendants.select do |klass|
|
|
36
|
+
klass.included_modules.any? { |mod| mod.in?([NoFlyList::TaggableRecord]) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
puts "Checking #{taggable_classes.size} taggable classes:\n\n"
|
|
40
|
+
|
|
41
|
+
taggable_classes.each do |klass|
|
|
42
|
+
puts "Checking Class: #{klass.name}"
|
|
43
|
+
|
|
44
|
+
# ANSI color codes
|
|
45
|
+
green = "\e[32m"
|
|
46
|
+
red = "\e[31m"
|
|
47
|
+
reset = "\e[0m"
|
|
48
|
+
|
|
49
|
+
# Check main table exists
|
|
50
|
+
begin
|
|
51
|
+
klass.table_exists?
|
|
52
|
+
puts " #{green}✓#{reset} Main table exists: #{klass.table_name}"
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
puts " #{red}✗#{reset} Error checking main table: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Dynamically find tag and tagging class names
|
|
58
|
+
tag_class_name = "#{klass.name}Tag"
|
|
59
|
+
tagging_class_name = "#{klass.name}::Tagging"
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
tag_class = Object.const_get(tag_class_name)
|
|
63
|
+
|
|
64
|
+
# Check tags table exists
|
|
65
|
+
if tag_class.table_exists?
|
|
66
|
+
puts " #{green}✓#{reset} Tags table exists: #{tag_class.table_name}"
|
|
67
|
+
else
|
|
68
|
+
puts " #{red}✗#{reset} Tags table missing: #{tag_class.table_name}"
|
|
69
|
+
end
|
|
70
|
+
rescue NameError
|
|
71
|
+
puts " #{red}✗#{reset} Tag class not found: #{tag_class_name}"
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
puts " #{red}✗#{reset} Error checking tag class: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
tagging_class = Object.const_get(tagging_class_name)
|
|
78
|
+
|
|
79
|
+
# Check taggings table exists
|
|
80
|
+
if tagging_class.table_exists?
|
|
81
|
+
puts " #{green}✓#{reset} Taggings table exists: #{tagging_class.table_name}"
|
|
82
|
+
else
|
|
83
|
+
puts " #{red}✗#{reset} Taggings table missing: #{tagging_class.table_name}"
|
|
84
|
+
end
|
|
85
|
+
rescue NameError
|
|
86
|
+
puts " #{red}✗#{reset} Tagging class not found: #{tagging_class_name}"
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
puts " #{red}✗#{reset} Error checking tagging class: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
puts "\n"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NoFlyList
|
|
4
|
+
# This module provides functionality for a tag table that contains global tags for a model.
|
|
5
|
+
#
|
|
6
|
+
# @example Usage
|
|
7
|
+
# class User::Tag < ApplicationRecord
|
|
8
|
+
# include NoFlyList::TagModel
|
|
9
|
+
# end
|
|
10
|
+
module TagRecord
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
delegate :to_s, to: :name
|
|
15
|
+
alias_attribute :tag_name, :name
|
|
16
|
+
def inspect
|
|
17
|
+
"#<#{self.class.name} id: #{id}, name: #{name} >"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mutation'
|
|
4
|
+
require_relative 'query'
|
|
5
|
+
|
|
6
|
+
module NoFlyList
|
|
7
|
+
module TaggableRecord
|
|
8
|
+
module Configuration
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def setup_tagging(taggable_klass, contexts, options = {})
|
|
12
|
+
contexts.each do |context|
|
|
13
|
+
setup = build_tag_setup(taggable_klass, context, options)
|
|
14
|
+
define_tag_structure(setup)
|
|
15
|
+
define_list_methods(setup)
|
|
16
|
+
Mutation.define_mutation_methods(setup) # Add mutation methods
|
|
17
|
+
Query.define_query_methods(setup) # Add query methods
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build_tag_setup(taggable_klass, context, options)
|
|
22
|
+
OpenStruct.new(
|
|
23
|
+
taggable_klass: taggable_klass,
|
|
24
|
+
context: context,
|
|
25
|
+
transformer: options.fetch(:transformer, ApplicationTagTransformer),
|
|
26
|
+
global: options.fetch(:global, false),
|
|
27
|
+
restrict_to_existing: options.fetch(:restrict_to_existing, false),
|
|
28
|
+
limit: options.fetch(:limit, nil),
|
|
29
|
+
tag_class_name: determine_tag_class_name(taggable_klass, options),
|
|
30
|
+
tagging_class_name: determine_tagging_class_name(taggable_klass, options)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def determine_tag_class_name(taggable_klass, options)
|
|
35
|
+
if options[:global]
|
|
36
|
+
Rails.application.config.no_fly_list.tag_class_name
|
|
37
|
+
else
|
|
38
|
+
options.fetch(:tag_class_name, "#{taggable_klass.name}Tag")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def determine_tagging_class_name(taggable_klass, options)
|
|
43
|
+
if options[:global]
|
|
44
|
+
Rails.application.config.no_fly_list.tagging_class_name
|
|
45
|
+
else
|
|
46
|
+
options.fetch(:tagging_class_name, "#{taggable_klass.name}::Tagging")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def define_tag_structure(setup)
|
|
51
|
+
define_tag_classes(setup) unless setup.global
|
|
52
|
+
define_tagging_associations(setup)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def define_tag_classes(setup)
|
|
56
|
+
base_class = find_abstract_class(setup.taggable_klass)
|
|
57
|
+
|
|
58
|
+
define_constant_in_namespace(setup.tag_class_name) do
|
|
59
|
+
create_tag_class(setup, base_class)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
define_constant_in_namespace(setup.tagging_class_name) do
|
|
63
|
+
create_tagging_class(setup, base_class)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def create_tag_class(setup, base_class)
|
|
68
|
+
Class.new(base_class) do
|
|
69
|
+
self.table_name = "#{setup.taggable_klass.table_name.singularize}_tags"
|
|
70
|
+
|
|
71
|
+
has_many :taggings, class_name: setup.tagging_class_name, dependent: :destroy
|
|
72
|
+
has_many :taggables, through: :taggings, source: :taggable, source_type: setup.taggable_klass.name
|
|
73
|
+
include NoFlyList::TagRecord
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def create_tagging_class(setup, base_class)
|
|
78
|
+
Class.new(base_class) do
|
|
79
|
+
self.table_name = "#{setup.taggable_klass.table_name.singularize}_taggings"
|
|
80
|
+
|
|
81
|
+
belongs_to :taggable, class_name: setup.taggable_klass.name, foreign_key: 'taggable_id'
|
|
82
|
+
belongs_to :tag, class_name: setup.tag_class_name
|
|
83
|
+
include NoFlyList::TaggingRecord
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def define_tagging_associations(setup)
|
|
88
|
+
singular_name = setup.context.to_s.singularize
|
|
89
|
+
|
|
90
|
+
setup.taggable_klass.class_eval do
|
|
91
|
+
has_many :"#{singular_name}_taggings",
|
|
92
|
+
-> { where(context: singular_name) },
|
|
93
|
+
class_name: setup.tagging_class_name,
|
|
94
|
+
foreign_key: 'taggable_id',
|
|
95
|
+
dependent: :destroy
|
|
96
|
+
|
|
97
|
+
has_many setup.context,
|
|
98
|
+
through: :"#{singular_name}_taggings",
|
|
99
|
+
source: :tag,
|
|
100
|
+
class_name: setup.tag_class_name
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def define_list_methods(setup)
|
|
105
|
+
context = setup.context
|
|
106
|
+
taggable_klass = setup.taggable_klass
|
|
107
|
+
|
|
108
|
+
# Define helper methods module for this context
|
|
109
|
+
helper_module = Module.new do
|
|
110
|
+
define_method :create_and_set_proxy do |instance_variable_name, setup|
|
|
111
|
+
tag_model = if setup.global
|
|
112
|
+
setup.tag_class_name.constantize
|
|
113
|
+
else
|
|
114
|
+
self.class.const_get("#{self.class.name}Tag")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
proxy = NoFlyList::TaggingProxy.new(
|
|
118
|
+
self,
|
|
119
|
+
tag_model,
|
|
120
|
+
setup.context,
|
|
121
|
+
transformer: setup.transformer,
|
|
122
|
+
restrict_to_existing: setup.restrict_to_existing,
|
|
123
|
+
limit: calculate_limit(setup.limit)
|
|
124
|
+
)
|
|
125
|
+
instance_variable_set(instance_variable_name, proxy)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
define_method :calculate_limit do |limit|
|
|
129
|
+
limit.is_a?(Proc) ? limit.call(self) : limit
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
define_method :reset_proxy_for do |context|
|
|
133
|
+
instance_variable_name = "@_#{context}_list_proxy"
|
|
134
|
+
remove_instance_variable(instance_variable_name) if instance_variable_defined?(instance_variable_name)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Include the helper methods
|
|
139
|
+
taggable_klass.include(helper_module)
|
|
140
|
+
|
|
141
|
+
# Define the public interface methods
|
|
142
|
+
taggable_klass.class_eval do
|
|
143
|
+
define_method "#{context}_list" do
|
|
144
|
+
instance_variable_name = "@_#{context}_list_proxy"
|
|
145
|
+
|
|
146
|
+
if instance_variable_defined?(instance_variable_name)
|
|
147
|
+
instance_variable_get(instance_variable_name)
|
|
148
|
+
else
|
|
149
|
+
create_and_set_proxy(instance_variable_name, setup)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
define_method "#{context}_list=" do |tag_list|
|
|
154
|
+
reset_proxy_for(context)
|
|
155
|
+
proxy = send("#{context}_list")
|
|
156
|
+
proxy.send("#{context}_list=", tag_list)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
define_method "reset_#{context}_list" do
|
|
160
|
+
reset_proxy_for(context)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def find_abstract_class(klass)
|
|
166
|
+
while klass && !klass.abstract_class?
|
|
167
|
+
klass = klass.superclass
|
|
168
|
+
break if klass == ActiveRecord::Base || klass.nil?
|
|
169
|
+
end
|
|
170
|
+
klass || ActiveRecord::Base
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def define_constant_in_namespace(const_name)
|
|
174
|
+
parts = const_name.split('::')
|
|
175
|
+
const_name = parts.pop
|
|
176
|
+
namespace = parts.join('::').safe_constantize || Object
|
|
177
|
+
return if namespace.const_defined?(const_name, false)
|
|
178
|
+
|
|
179
|
+
namespace.const_set(const_name, yield)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NoFlyList
|
|
4
|
+
module TaggableRecord
|
|
5
|
+
module Mutation
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def define_mutation_methods(setup)
|
|
9
|
+
context = setup.context
|
|
10
|
+
taggable_klass = setup.taggable_klass
|
|
11
|
+
|
|
12
|
+
taggable_klass.class_eval do
|
|
13
|
+
# Add multiple tags
|
|
14
|
+
define_method "add_#{context}" do |*tags|
|
|
15
|
+
send("#{context}_list").add(*tags)
|
|
16
|
+
end
|
|
17
|
+
alias_method "add_#{context}=", "add_#{context}"
|
|
18
|
+
|
|
19
|
+
# Remove multiple tags
|
|
20
|
+
define_method "remove_#{context}" do |*tags|
|
|
21
|
+
send("#{context}_list").remove(*tags)
|
|
22
|
+
end
|
|
23
|
+
alias_method "remove_#{context}=", "remove_#{context}"
|
|
24
|
+
|
|
25
|
+
# Set tags (replaces existing)
|
|
26
|
+
define_method "set_#{context}" do |*tags|
|
|
27
|
+
send("#{context}_list=", tags)
|
|
28
|
+
end
|
|
29
|
+
alias_method "set_#{context}=", "set_#{context}"
|
|
30
|
+
|
|
31
|
+
# Clear all tags
|
|
32
|
+
define_method "clear_#{context}" do
|
|
33
|
+
send("#{context}_list").clear
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
define_method "clear_#{context}!" do
|
|
37
|
+
send("#{context}_list").clear!
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NoFlyList
|
|
4
|
+
module TaggableRecord
|
|
5
|
+
module Query
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def define_query_methods(setup)
|
|
9
|
+
context = setup.context
|
|
10
|
+
taggable_klass = setup.taggable_klass
|
|
11
|
+
singular_name = context.to_s.singularize
|
|
12
|
+
|
|
13
|
+
taggable_klass.class_eval do
|
|
14
|
+
# Find records with any of the specified tags
|
|
15
|
+
scope "with_any_#{context}", lambda { |*tags|
|
|
16
|
+
tags = tags.flatten.compact.uniq
|
|
17
|
+
return none if tags.empty?
|
|
18
|
+
|
|
19
|
+
joins(:"#{singular_name}_taggings")
|
|
20
|
+
.joins(context)
|
|
21
|
+
.where("#{singular_name}_taggings": { context: singular_name })
|
|
22
|
+
.where(context => { name: tags })
|
|
23
|
+
.distinct
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Find records without any tags
|
|
27
|
+
scope "without_#{context}", lambda {
|
|
28
|
+
where.not(
|
|
29
|
+
id: setup.tagging_class_name.constantize.where(context: singular_name).select(:taggable_id)
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Find records with all specified tags
|
|
34
|
+
scope "with_all_#{context}", lambda { |*tags|
|
|
35
|
+
tags = tags.flatten.compact.uniq
|
|
36
|
+
return none if tags.empty?
|
|
37
|
+
|
|
38
|
+
tag_count = tags.size
|
|
39
|
+
joins(:"#{singular_name}_taggings")
|
|
40
|
+
.joins(context)
|
|
41
|
+
.where("#{singular_name}_taggings": { context: singular_name })
|
|
42
|
+
.where(context => { name: tags })
|
|
43
|
+
.group(:id)
|
|
44
|
+
.having("COUNT(DISTINCT #{context}.name) = ?", tag_count)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Find records without specific tags
|
|
48
|
+
scope "without_any_#{context}", lambda { |*tags|
|
|
49
|
+
tags = tags.flatten.compact.uniq
|
|
50
|
+
return all if tags.empty?
|
|
51
|
+
|
|
52
|
+
where.not(
|
|
53
|
+
id: joins(:"#{singular_name}_taggings")
|
|
54
|
+
.joins(context)
|
|
55
|
+
.where("#{singular_name}_taggings": { context: singular_name })
|
|
56
|
+
.where(context => { name: tags })
|
|
57
|
+
.select(:id)
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Find records with exactly these tags
|
|
62
|
+
scope "with_exact_#{context}", lambda { |*tags|
|
|
63
|
+
tags = tags.flatten.compact.uniq
|
|
64
|
+
|
|
65
|
+
if tags.empty?
|
|
66
|
+
send("without_#{context}")
|
|
67
|
+
else
|
|
68
|
+
# Get records with the exact count of specified tags
|
|
69
|
+
having_exact_tags =
|
|
70
|
+
joins(:"#{singular_name}_taggings")
|
|
71
|
+
.joins(context)
|
|
72
|
+
.where("#{singular_name}_taggings": { context: singular_name })
|
|
73
|
+
.where(context => { name: tags })
|
|
74
|
+
.group(:id)
|
|
75
|
+
.having("COUNT(DISTINCT #{context}.name) = ?", tags.size)
|
|
76
|
+
.select(:id)
|
|
77
|
+
|
|
78
|
+
# Exclude records that have any other tags
|
|
79
|
+
having_exact_tags.where.not(
|
|
80
|
+
id: joins(:"#{singular_name}_taggings")
|
|
81
|
+
.joins(context)
|
|
82
|
+
.where("#{singular_name}_taggings": { context: singular_name })
|
|
83
|
+
.where.not(context => { name: tags })
|
|
84
|
+
.select(:id)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Count tags for each record
|
|
90
|
+
scope "#{context}_count", lambda {
|
|
91
|
+
left_joins(:"#{singular_name}_taggings")
|
|
92
|
+
.where("#{singular_name}_taggings": { context: singular_name })
|
|
93
|
+
.group(:id)
|
|
94
|
+
.select("#{table_name}.*, COUNT(DISTINCT #{singular_name}_taggings.id) as #{context}_count")
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NoFlyList
|
|
4
|
+
module TaggableRecord
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_save :save_tag_proxies
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def save_tag_proxies
|
|
14
|
+
instance_variables.each do |var|
|
|
15
|
+
next unless var.to_s.match?(/_list_proxy$/)
|
|
16
|
+
|
|
17
|
+
proxy = instance_variable_get(var)
|
|
18
|
+
return false unless proxy.save
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class_methods do
|
|
23
|
+
def has_tags(*contexts, **options)
|
|
24
|
+
Configuration.setup_tagging(self, contexts, options)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NoFlyList
|
|
4
|
+
class TaggingProxy
|
|
5
|
+
include Enumerable
|
|
6
|
+
include ActiveModel::Conversion
|
|
7
|
+
include ActiveModel::Validations
|
|
8
|
+
extend ActiveModel::Naming
|
|
9
|
+
|
|
10
|
+
delegate :blank?, :present?, :each, :==, to: :current_list
|
|
11
|
+
attr_reader :model, :tag_model, :context, :transformer
|
|
12
|
+
|
|
13
|
+
validate :validate_limit
|
|
14
|
+
validate :validate_existing_tags
|
|
15
|
+
|
|
16
|
+
def initialize(model, tag_model, context,
|
|
17
|
+
transformer: ApplicationTagTransformer,
|
|
18
|
+
restrict_to_existing: false,
|
|
19
|
+
limit: nil)
|
|
20
|
+
@model = model
|
|
21
|
+
@tag_model = tag_model
|
|
22
|
+
@context = context
|
|
23
|
+
@transformer = transformer.is_a?(String) ? transformer.constantize : transformer
|
|
24
|
+
@restrict_to_existing = restrict_to_existing
|
|
25
|
+
@limit = limit
|
|
26
|
+
@pending_changes = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def method_missing(method_name, *args)
|
|
30
|
+
if current_list.respond_to?(method_name)
|
|
31
|
+
current_list.send(method_name, *args)
|
|
32
|
+
else
|
|
33
|
+
case method_name.to_s
|
|
34
|
+
when /\A(.+)_list=\z/
|
|
35
|
+
set_list(::Regexp.last_match(1), args.first)
|
|
36
|
+
when /\A(.+)_list\z/
|
|
37
|
+
get_list(::Regexp.last_match(1))
|
|
38
|
+
else
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def respond_to_missing?(method_name, _include_private = false)
|
|
45
|
+
current_list.respond_to?(method_name) ||
|
|
46
|
+
method_name.to_s =~ /\A(.+)_list(=)?\z/
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def coerce(other)
|
|
50
|
+
[other, to_a]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_ary
|
|
54
|
+
current_list
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] true if the proxy is valid
|
|
58
|
+
def save
|
|
59
|
+
return true unless @pending_changes.any?
|
|
60
|
+
|
|
61
|
+
if valid?
|
|
62
|
+
@model.transaction do
|
|
63
|
+
@model.send(@context.to_s).destroy_all
|
|
64
|
+
@pending_changes.each do |tag_name|
|
|
65
|
+
tag = find_or_create_tag(tag_name)
|
|
66
|
+
@model.send(@context.to_s) << tag if tag
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
refresh_from_database
|
|
70
|
+
true
|
|
71
|
+
else
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @raise [ActiveModel::ValidationError] if the proxy is not valid
|
|
77
|
+
# @return [Boolean] true if the proxy is valid and the changes were saved
|
|
78
|
+
def save!
|
|
79
|
+
valid? || raise(ActiveModel::ValidationError, self)
|
|
80
|
+
save
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Integer]
|
|
84
|
+
def count
|
|
85
|
+
@model.send(@context.to_s).count
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Integer]
|
|
89
|
+
def size
|
|
90
|
+
if @pending_changes.any?
|
|
91
|
+
@pending_changes.size
|
|
92
|
+
else
|
|
93
|
+
@model.send(@context.to_s).size
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Array<String>]
|
|
98
|
+
def to_a
|
|
99
|
+
current_list
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [String]
|
|
103
|
+
def to_s
|
|
104
|
+
transformer.recreate_string(current_list)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [String] The name of the parser used to transform tags
|
|
108
|
+
def transformer_name
|
|
109
|
+
@transformer_name ||= transformer.name
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [String]
|
|
113
|
+
def inspect
|
|
114
|
+
"#<#{self.class.name} tags=#{current_list.inspect} transformer_with=#{transformer_name} >"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def add(tag)
|
|
118
|
+
return self if limit_reached?
|
|
119
|
+
|
|
120
|
+
new_tags = transformer.parse_tags(tag)
|
|
121
|
+
return self if new_tags.empty?
|
|
122
|
+
|
|
123
|
+
@pending_changes = current_list + new_tags
|
|
124
|
+
@pending_changes.uniq!
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def add!(tag)
|
|
129
|
+
add(tag)
|
|
130
|
+
save
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def remove(tag)
|
|
134
|
+
@pending_changes = current_list - [tag.to_s.strip]
|
|
135
|
+
self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def remove!(tag)
|
|
139
|
+
remove(tag)
|
|
140
|
+
save
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def clear
|
|
144
|
+
@pending_changes = []
|
|
145
|
+
self
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def clear!
|
|
149
|
+
@model.send(@context.to_s).destroy_all
|
|
150
|
+
@pending_changes = []
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def include?(tag)
|
|
155
|
+
current_list.include?(tag.to_s.strip)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def empty?
|
|
159
|
+
current_list.empty?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def persisted?
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def set_list(_context, value)
|
|
169
|
+
@pending_changes = transformer.parse_tags(value)
|
|
170
|
+
valid? # Just check validity without raising
|
|
171
|
+
self
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def get_list(_context)
|
|
175
|
+
current_list
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def current_list
|
|
179
|
+
if @pending_changes.any?
|
|
180
|
+
@pending_changes
|
|
181
|
+
else
|
|
182
|
+
@model.send(@context.to_s).pluck(:name)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def refresh_from_database
|
|
187
|
+
@pending_changes = []
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_limit
|
|
191
|
+
return unless @limit
|
|
192
|
+
return if @pending_changes.size <= @limit
|
|
193
|
+
|
|
194
|
+
errors.add(:base, "Cannot have more than #{@limit} tags (attempting to save #{@pending_changes.size})")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def validate_existing_tags
|
|
198
|
+
return unless @restrict_to_existing
|
|
199
|
+
return if @pending_changes.empty?
|
|
200
|
+
|
|
201
|
+
existing_tags = @tag_model.where(name: @pending_changes).pluck(:name)
|
|
202
|
+
missing_tags = @pending_changes - existing_tags
|
|
203
|
+
|
|
204
|
+
return unless missing_tags.any?
|
|
205
|
+
|
|
206
|
+
errors.add(:base, "The following tags do not exist: #{missing_tags.join(', ')}")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def find_or_create_tag(tag_name)
|
|
210
|
+
if @restrict_to_existing
|
|
211
|
+
@tag_model.find_by(name: tag_name)
|
|
212
|
+
else
|
|
213
|
+
@tag_model.find_or_create_by(name: tag_name)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def limit_reached?
|
|
218
|
+
@limit && current_list.size >= @limit
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NoFlyList
|
|
4
|
+
# This module provides functionality for a tag table that contains global tags for a model.
|
|
5
|
+
#
|
|
6
|
+
# This concern can be included in models that represent tags to manage global tags across different records.
|
|
7
|
+
#
|
|
8
|
+
# @example Usage
|
|
9
|
+
# class User::Tag < ApplicationRecord
|
|
10
|
+
# include NoFlyList::TaggingModel
|
|
11
|
+
# end
|
|
12
|
+
module TaggingRecord
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
delegate :tag_name, to: :tag
|
|
16
|
+
|
|
17
|
+
def inspect
|
|
18
|
+
"#<#{self.class.name} id: #{id}, tag_name: #{tag_name} >"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NoFlyList
|
|
4
|
+
# = NoFlyList Test Helper
|
|
5
|
+
#
|
|
6
|
+
# Include <tt>NoFlyList::TestHelper</tt> in your test case to get access to the assertion methods.
|
|
7
|
+
module TestHelper
|
|
8
|
+
def assert_taggable_record(klass, *contexts)
|
|
9
|
+
assert klass.respond_to?(:has_tags), "#{klass} does not respond to has_tags"
|
|
10
|
+
contexts.each do |context|
|
|
11
|
+
assert klass.new.respond_to?(:"#{context}_list"), "#{klass} does not respond to #{context}_list"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/no_fly_list.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require 'active_support'
|
|
5
|
+
require 'active_support/rails'
|
|
6
|
+
require 'active_support/core_ext/numeric/time'
|
|
7
|
+
require_relative 'no_fly_list/version'
|
|
8
|
+
require 'no_fly_list/railtie' if defined?(Rails)
|
|
9
|
+
require 'ostruct'
|
|
10
|
+
|
|
11
|
+
module NoFlyList
|
|
12
|
+
extend ActiveSupport::Autoload
|
|
13
|
+
# Global tagging tables
|
|
14
|
+
autoload :ApplicationTag
|
|
15
|
+
autoload :ApplicationTagging
|
|
16
|
+
|
|
17
|
+
# Common tagging tables
|
|
18
|
+
autoload :TaggableRecord
|
|
19
|
+
autoload_under 'taggable_record' do
|
|
20
|
+
autoload :Configuration
|
|
21
|
+
end
|
|
22
|
+
autoload :TaggingRecord
|
|
23
|
+
autoload :TagRecord
|
|
24
|
+
|
|
25
|
+
autoload :TaggingProxy
|
|
26
|
+
|
|
27
|
+
autoload :TestHelper
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: no_fly_list
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Abdelkader Boudih
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2024-12-06 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: ostruct
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0'
|
|
41
|
+
description: Tagging system for ActiveRecord models inspired by the TSA
|
|
42
|
+
email:
|
|
43
|
+
- terminale@gmail.com
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- lib/generators/no_fly_list/install_generator.rb
|
|
49
|
+
- lib/generators/no_fly_list/models_generator.rb
|
|
50
|
+
- lib/generators/no_fly_list/tagging_generator.rb
|
|
51
|
+
- lib/generators/no_fly_list/templates/application_tag.rb.erb
|
|
52
|
+
- lib/generators/no_fly_list/templates/application_tagging.rb.erb
|
|
53
|
+
- lib/generators/no_fly_list/templates/create_application_tagging_table.rb.erb
|
|
54
|
+
- lib/generators/no_fly_list/templates/create_tagging_table.rb.erb
|
|
55
|
+
- lib/generators/no_fly_list/templates/tag_model.rb.erb
|
|
56
|
+
- lib/generators/no_fly_list/templates/tag_parser.rb
|
|
57
|
+
- lib/generators/no_fly_list/templates/tagging_model.rb.erb
|
|
58
|
+
- lib/generators/no_fly_list/transformer_generator.rb
|
|
59
|
+
- lib/no_fly_list.rb
|
|
60
|
+
- lib/no_fly_list/application_tag.rb
|
|
61
|
+
- lib/no_fly_list/application_tagging.rb
|
|
62
|
+
- lib/no_fly_list/railtie.rb
|
|
63
|
+
- lib/no_fly_list/railties/tasks.rake
|
|
64
|
+
- lib/no_fly_list/tag_record.rb
|
|
65
|
+
- lib/no_fly_list/taggable_record.rb
|
|
66
|
+
- lib/no_fly_list/taggable_record/configuration.rb
|
|
67
|
+
- lib/no_fly_list/taggable_record/mutation.rb
|
|
68
|
+
- lib/no_fly_list/taggable_record/query.rb
|
|
69
|
+
- lib/no_fly_list/tagging_proxy.rb
|
|
70
|
+
- lib/no_fly_list/tagging_record.rb
|
|
71
|
+
- lib/no_fly_list/test_helper.rb
|
|
72
|
+
- lib/no_fly_list/version.rb
|
|
73
|
+
homepage: https://github.com/contriboss/no_fly_list
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
rubygems_mfa_required: 'true'
|
|
78
|
+
post_install_message:
|
|
79
|
+
rdoc_options: []
|
|
80
|
+
require_paths:
|
|
81
|
+
- lib
|
|
82
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: 3.2.0
|
|
87
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '0'
|
|
92
|
+
requirements: []
|
|
93
|
+
rubygems_version: 3.5.22
|
|
94
|
+
signing_key:
|
|
95
|
+
specification_version: 4
|
|
96
|
+
summary: Tagging system for ActiveRecord models
|
|
97
|
+
test_files: []
|