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 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: []