no_fly_list 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,3 @@
1
+ class ApplicationTag < <%= connection_abstract_class_name %>
2
+ include NoFlyList::ApplicationTag
3
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationTagging < <%= connection_abstract_class_name %>
2
+ include NoFlyList::ApplicationTagging
3
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoFlyList
4
+ VERSION = '0.1.0'
5
+ end
@@ -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: []