no_fly_list 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fbe92a4791b7cf24e4a2ca876b1c7f62cd5a37d653cdcad45f752a393c04f16
4
- data.tar.gz: 00c74e1f82fea31e30f517ff102d5fc41c714392c73b05c841737837b521b370
3
+ metadata.gz: fddc142a9f89b7e7fae0c35c5285ffbdf1a68b0164ffcaf889ea70a572297933
4
+ data.tar.gz: 6376fad323f9efbfaee941472ba977460dedc5a3a46e994df7a4bef2a8bb7d64
5
5
  SHA512:
6
- metadata.gz: 590cf033d9ed9fa653341b8aedb2e731244e45505b0945147e7da1ca0fac264e7f161cd5bb5608e95c5567f17fdba5837338e851e1dc76d707b8bfef379974ce
7
- data.tar.gz: b55be12266e3e9ee2deeb8fa82aa4e405762ad8a2f3bb6643933f30dab10a918b5add73ffb7a29b17f741be404afc952027e53e85c4253f8deee4d6630b3966b
6
+ metadata.gz: ec568b5205e74c5d856d07122d6325585c12416c33f65ee2852207fa17288f1676a09d2f0757c70a6f88a091510e9d9060f0cb7446ca8d493f3397ade1bd5020
7
+ data.tar.gz: 76b620b9ce76b112229c770a5a2221bdacb25b9ccc5f85bfb95c77623d26dee7114967323f91dd9b5a4f896355ab0b27cbba948abfbd7effc19f4ed1d1156809
@@ -1,15 +1,25 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def up
3
3
  create_table :application_tags do |t|
4
- t.string :name
5
- t.timestamps
4
+ t.string :name, null: false, index: { unique: true }
5
+ t.timestamp :created_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
6
+ t.timestamp :updated_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
6
7
  end
7
8
 
8
9
  create_table :application_taggings do |t|
9
- t.bigint :tag_id, null: false
10
- t.foreign_key :application_tags, column: :tag_id
10
+ t.references :tag, null: false, foreign_key: { to_table: :application_tags }
11
11
  t.references :taggable, polymorphic: true, null: false
12
- t.timestamps
12
+ t.string :context, null: false
13
+ t.timestamp :created_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
14
+ t.timestamp :updated_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
15
+
16
+ # Add index for uniqueness and performance
17
+ t.index [:taggable_type, :taggable_id, :context, :tag_id],
18
+ unique: true,
19
+ name: 'index_app_taggings_uniqueness'
20
+
21
+ # Add index for faster lookups by context
22
+ t.index [:context]
13
23
  end
14
24
  end
15
25
  end
@@ -2,14 +2,16 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version
2
2
  def change
3
3
  create_table :<%= tag_table_name %>, id: :bigint do |t|
4
4
  t.string :name, null: false
5
- t.timestamps default: -> { 'CURRENT_TIMESTAMP' }
5
+ t.timestamp :created_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
6
+ t.timestamp :updated_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
6
7
  end
7
8
 
8
9
  create_table :<%= tagging_table_name %> do |t|
9
10
  t.column :taggable_id, :bigint, null: false, index: true # Change to :uuid if you are using UUIDs
10
11
  t.column :tag_id, :bigint, null: false, index: true
11
12
  t.string :context, null: false
12
- t.timestamps default: -> { 'CURRENT_TIMESTAMP' }
13
+ t.timestamp :created_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
14
+ t.timestamp :updated_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
13
15
  end
14
16
 
15
17
  add_index :<%= tag_table_name %>, :name, unique: true
@@ -8,6 +8,7 @@ require 'rails/generators/named_base'
8
8
  unless defined?(ApplicationTagTransformer)
9
9
  module NoFlyList
10
10
  module Generators
11
+ # bin/rails g no_fly_list:transformer
11
12
  class TransformerGenerator < Rails::Generators::Base
12
13
  source_root File.expand_path('templates', __dir__)
13
14
  def create_tag_transformer_file
@@ -12,12 +12,5 @@ module NoFlyList
12
12
  # end
13
13
  module ApplicationTag
14
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
15
  end
23
16
  end
@@ -12,11 +12,5 @@ module NoFlyList
12
12
  # end
13
13
  module ApplicationTagging
14
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
15
  end
22
16
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NoFlyList
4
- # This module provides functionality for a tag table that contains global tags for a model.
4
+ # This module provides functionality for a tag table that contains tags for a model.
5
5
  #
6
6
  # @example Usage
7
7
  # class User::Tag < ApplicationRecord
8
- # include NoFlyList::TagModel
8
+ # include NoFlyList::TagRecord
9
9
  # end
10
10
  module TagRecord
11
11
  extend ActiveSupport::Concern
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoFlyList
4
+ class Config
5
+ attr_reader :tag_contexts, :taggable_class, :adapter
6
+
7
+ def initialize(taggable_class = nil)
8
+ @tag_contexts = {}
9
+ @taggable_class = taggable_class
10
+ @adapter = determine_adapter
11
+ end
12
+
13
+ def add_context(context, options = {})
14
+ context = context.to_sym
15
+ tag_class_name = determine_tag_class_name(options)
16
+ tagging_class_name = determine_tagging_class_name(options)
17
+
18
+ @tag_contexts[context] = {
19
+ taggable_class: @taggable_class.to_s,
20
+ tag_class_name: tag_class_name,
21
+ tagging_class_name: tagging_class_name,
22
+ transformer: options.fetch(:transformer, ApplicationTagTransformer).to_s,
23
+ polymorphic: options.fetch(:polymorphic, false),
24
+ restrict_to_existing: options.fetch(:restrict_to_existing, false),
25
+ limit: options.fetch(:limit, nil),
26
+ case_sensitive: options.fetch(:case_sensitive, true),
27
+ adapter: @adapter
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ def determine_adapter
34
+ case taggable_class.connection.adapter_name.downcase
35
+ when 'postgresql'
36
+ :postgresql
37
+ when 'mysql2'
38
+ :mysql
39
+ else
40
+ :sqlite
41
+ end
42
+ end
43
+
44
+ def determine_tag_class_name(options)
45
+ if options[:polymorphic]
46
+ Rails.application.config.no_fly_list.tag_class_name
47
+ else
48
+ options.fetch(:tag_class_name, "#{@taggable_class}Tag")
49
+ end
50
+ end
51
+
52
+ def determine_tagging_class_name(options)
53
+ if options[:polymorphic]
54
+ Rails.application.config.no_fly_list.tagging_class_name
55
+ else
56
+ options.fetch(:tagging_class_name, "#{@taggable_class}::Tagging")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -2,56 +2,64 @@
2
2
 
3
3
  require_relative 'mutation'
4
4
  require_relative 'query'
5
+ require_relative 'tag_setup'
5
6
 
6
7
  module NoFlyList
7
8
  module TaggableRecord
9
+ # Configuration module handles the setup and structure of tagging functionality
10
+ # This includes creating necessary classes, setting up associations, and defining
11
+ # the interface for tag manipulation
8
12
  module Configuration
9
13
  module_function
10
14
 
15
+ # Main entry point for setting up tagging functionality on a model
16
+ # @param taggable_klass [Class] The model class to make taggable
17
+ # @param contexts [Array<Symbol>] The contexts to create tags for (e.g., :tags, :colors)
18
+ # @param options [Hash] Configuration options for tagging behavior
11
19
  def setup_tagging(taggable_klass, contexts, options = {})
12
20
  contexts.each do |context|
13
21
  setup = build_tag_setup(taggable_klass, context, options)
14
22
  define_tag_structure(setup)
15
23
  define_list_methods(setup)
16
- Mutation.define_mutation_methods(setup) # Add mutation methods
17
- Query.define_query_methods(setup) # Add query methods
24
+ Mutation.define_mutation_methods(setup)
25
+ Query.define_query_methods(setup)
18
26
  end
19
27
  end
20
28
 
29
+ # Creates a new TagSetup instance with the given configuration
21
30
  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
- )
31
+ TagSetup.new(taggable_klass, context, options)
32
32
  end
33
33
 
34
+ # Determines the appropriate class name for tags based on configuration
35
+ # For global tags, uses application-wide tag class
36
+ # For local tags, creates model-specific tag classes
34
37
  def determine_tag_class_name(taggable_klass, options)
35
- if options[:global]
38
+ if options[:polymorphic]
36
39
  Rails.application.config.no_fly_list.tag_class_name
37
40
  else
38
41
  options.fetch(:tag_class_name, "#{taggable_klass.name}Tag")
39
42
  end
40
43
  end
41
44
 
45
+ # Determines the appropriate class name for taggings based on configuration
46
+ # For global tags, uses application-wide tagging class
47
+ # For local tags, creates model-specific tagging classes
42
48
  def determine_tagging_class_name(taggable_klass, options)
43
- if options[:global]
49
+ if options[:polymorphic]
44
50
  Rails.application.config.no_fly_list.tagging_class_name
45
51
  else
46
52
  options.fetch(:tagging_class_name, "#{taggable_klass.name}::Tagging")
47
53
  end
48
54
  end
49
55
 
56
+ # Sets up the complete tag structure including classes and associations
50
57
  def define_tag_structure(setup)
51
- define_tag_classes(setup) unless setup.global
58
+ define_tag_classes(setup) unless setup.polymorphic
52
59
  define_tagging_associations(setup)
53
60
  end
54
61
 
62
+ # Creates the tag and tagging classes for local (non-global) tags
55
63
  def define_tag_classes(setup)
56
64
  base_class = find_abstract_class(setup.taggable_klass)
57
65
 
@@ -64,40 +72,159 @@ module NoFlyList
64
72
  end
65
73
  end
66
74
 
75
+ # Creates a new tag class with appropriate configuration
67
76
  def create_tag_class(setup, base_class)
68
77
  Class.new(base_class) do
69
78
  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
79
  include NoFlyList::TagRecord
74
80
  end
75
81
  end
76
82
 
83
+ # Creates a new tagging class with appropriate configuration
77
84
  def create_tagging_class(setup, base_class)
85
+ setup.context.to_s.singularize
86
+
78
87
  Class.new(base_class) do
79
88
  self.table_name = "#{setup.taggable_klass.table_name.singularize}_taggings"
80
89
 
81
- belongs_to :taggable, class_name: setup.taggable_klass.name, foreign_key: 'taggable_id'
82
- belongs_to :tag, class_name: setup.tag_class_name
90
+ # Add the basic associations
91
+ belongs_to :tag,
92
+ class_name: setup.tag_class_name,
93
+ foreign_key: 'tag_id'
94
+
95
+ belongs_to :taggable,
96
+ class_name: setup.taggable_klass.name,
97
+ foreign_key: 'taggable_id'
98
+
83
99
  include NoFlyList::TaggingRecord
84
100
  end
85
101
  end
86
102
 
103
+ # Sets up all necessary associations between tags, taggings, and the taggable model
87
104
  def define_tagging_associations(setup)
88
105
  singular_name = setup.context.to_s.singularize
89
106
 
90
- setup.taggable_klass.class_eval do
91
- has_many :"#{singular_name}_taggings",
107
+ if setup.polymorphic
108
+ setup_polymorphic_tag_associations(setup, singular_name)
109
+ else
110
+ setup_local_tag_associations(setup, singular_name)
111
+ end
112
+
113
+ setup_taggable_associations(setup, singular_name)
114
+ end
115
+
116
+ # Sets up associations for polymorphic tags
117
+ def setup_polymorphic_tag_associations(setup, singular_name)
118
+ # Set up the tag model associations
119
+ setup.tag_class_name.constantize.class_eval do
120
+ # Fix: Use 'tagging' for the join association when context is 'tag'
121
+ association_name = (singular_name == 'tag' ? :taggings : :"#{singular_name}_taggings")
122
+
123
+ has_many association_name,
92
124
  -> { where(context: singular_name) },
93
125
  class_name: setup.tagging_class_name,
94
- foreign_key: 'taggable_id',
126
+ foreign_key: 'tag_id',
95
127
  dependent: :destroy
96
128
 
129
+ # Fix: Use consistent naming for through association
97
130
  has_many setup.context,
131
+ through: association_name,
132
+ source: :taggable,
133
+ source_type: setup.taggable_klass.name
134
+ end
135
+
136
+ # Set up the tagging model with global scope
137
+ setup.tagging_class_name.constantize.class_eval do
138
+ belongs_to :tag,
139
+ class_name: setup.tag_class_name,
140
+ foreign_key: 'tag_id'
141
+
142
+ belongs_to :taggable,
143
+ polymorphic: true
144
+
145
+ validates :tag, :taggable, :context, presence: true
146
+
147
+ # Add scope for specific taggable type
148
+ scope :for_taggable_type, ->(type) { where(taggable_type: type) }
149
+
150
+ validates :tag_id, uniqueness: {
151
+ scope: %i[taggable_type taggable_id context],
152
+ message: 'has already been tagged on this record in this context'
153
+ }
154
+ end
155
+ end
156
+
157
+ # Sets up associations for local (non-global) tags
158
+ def setup_local_tag_associations(setup, singular_name)
159
+ # Set up tag class associations
160
+ setup.tag_class_name.constantize.class_eval do
161
+ has_many :"#{singular_name}_taggings",
162
+ -> { where(context: singular_name) },
163
+ class_name: setup.tagging_class_name,
164
+ foreign_key: 'tag_id',
165
+ dependent: :destroy
166
+
167
+ has_many :"#{singular_name}_taggables",
98
168
  through: :"#{singular_name}_taggings",
99
- source: :tag,
100
- class_name: setup.tag_class_name
169
+ source: :taggable
170
+ end
171
+
172
+ # Set up tagging class associations
173
+ setup.tagging_class_name.constantize.class_eval do
174
+ belongs_to :tag,
175
+ class_name: setup.tag_class_name,
176
+ foreign_key: 'tag_id'
177
+
178
+ # For local tags, we use a simple belongs_to without polymorphic
179
+ belongs_to :taggable,
180
+ class_name: setup.taggable_klass.name,
181
+ foreign_key: 'taggable_id'
182
+
183
+ validates :tag, :taggable, :context, presence: true
184
+ validates :tag_id, uniqueness: {
185
+ scope: %i[taggable_id context],
186
+ message: 'has already been tagged on this record in this context'
187
+ }
188
+ end
189
+ end
190
+
191
+ # Sets up associations on the taggable model
192
+ def setup_taggable_associations(setup, singular_name)
193
+ setup.taggable_klass.class_eval do
194
+ if setup.polymorphic
195
+ # Global tags need polymorphic associations
196
+ has_many :"#{singular_name}_taggings",
197
+ -> { where(context: singular_name) },
198
+ class_name: setup.tagging_class_name,
199
+ foreign_key: 'taggable_id',
200
+ as: :taggable,
201
+ dependent: :destroy
202
+
203
+ has_many setup.context,
204
+ through: :"#{singular_name}_taggings",
205
+ source: :tag,
206
+ class_name: setup.tag_class_name do
207
+ def by_type(taggable_type)
208
+ where(taggings: { taggable_type: taggable_type })
209
+ end
210
+
211
+ def shared_with(other_taggable)
212
+ where(id: other_taggable.send(proxy_association.name).pluck(:id))
213
+ end
214
+ end
215
+ else
216
+ # Local tags should use simple associations
217
+ has_many :"#{singular_name}_taggings",
218
+ -> { where(context: singular_name) },
219
+ class_name: setup.tagging_class_name,
220
+ foreign_key: 'taggable_id',
221
+ dependent: :destroy
222
+
223
+ has_many setup.context,
224
+ through: :"#{singular_name}_taggings",
225
+ source: :tag,
226
+ class_name: setup.tag_class_name
227
+ end
101
228
  end
102
229
  end
103
230
 
@@ -108,13 +235,13 @@ module NoFlyList
108
235
  # Define helper methods module for this context
109
236
  helper_module = Module.new do
110
237
  define_method :create_and_set_proxy do |instance_variable_name, setup|
111
- tag_model = if setup.global
238
+ tag_model = if setup.polymorphic
112
239
  setup.tag_class_name.constantize
113
240
  else
114
241
  self.class.const_get("#{self.class.name}Tag")
115
242
  end
116
243
 
117
- proxy = NoFlyList::TaggingProxy.new(
244
+ proxy = TaggingProxy.new(
118
245
  self,
119
246
  tag_model,
120
247
  setup.context,
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoFlyList
4
+ module TaggableRecord
5
+ module Query
6
+ module MysqlStrategy
7
+ extend BaseStrategy
8
+
9
+ module_function
10
+
11
+ def define_query_methods(setup)
12
+ context = setup.context
13
+ taggable_klass = setup.taggable_klass
14
+ tagging_klass = setup.tagging_class_name.constantize
15
+ tagging_table = tagging_klass.arel_table # Arel
16
+ tag_klass = setup.tag_class_name.constantize
17
+ tag_table = tag_klass.arel_table
18
+ singular_name = context.to_s.singularize
19
+
20
+ taggable_klass.class_eval do
21
+ # Find records with any of the specified tags
22
+ scope "with_any_#{context}", lambda { |*tags|
23
+ tags = tags.flatten.compact.uniq
24
+ return none if tags.empty?
25
+
26
+ query = Arel::SelectManager.new(self)
27
+ .from(table_name)
28
+ .project(arel_table[primary_key])
29
+ .distinct
30
+ .join(tagging_table).on(tagging_table[:taggable_id].eq(arel_table[primary_key]))
31
+ .join(tag_table).on(tag_table[:id].eq(tagging_table[:tag_id]))
32
+ .where(tagging_table[:context].eq(singular_name))
33
+ .where(tag_table[:name].in(tags))
34
+
35
+ where(arel_table[primary_key].in(query))
36
+ }
37
+
38
+ scope "with_all_#{context}", lambda { |*tags|
39
+ tags = tags.flatten.compact.uniq
40
+ return none if tags.empty?
41
+
42
+ count_function = Arel::Nodes::NamedFunction.new(
43
+ 'COUNT',
44
+ [Arel::Nodes::NamedFunction.new('DISTINCT', [tag_table[:name]])]
45
+ )
46
+
47
+ query = Arel::SelectManager.new(self)
48
+ .from(table_name)
49
+ .project(arel_table[primary_key])
50
+ .join(tagging_table).on(tagging_table[:taggable_id].eq(arel_table[primary_key]))
51
+ .join(tag_table).on(tag_table[:id].eq(tagging_table[:tag_id]))
52
+ .where(tagging_table[:context].eq(singular_name))
53
+ .where(tag_table[:name].in(tags))
54
+ .group(arel_table[primary_key])
55
+ .having(count_function.eq(tags.size))
56
+
57
+ where(arel_table[primary_key].in(query))
58
+ }
59
+
60
+ # Find records without any of the specified tags
61
+ scope "without_any_#{context}", lambda { |*tags|
62
+ tags = tags.flatten.compact.uniq
63
+ return all if tags.empty?
64
+
65
+ subquery = joins(tagging_table)
66
+ .where(tagging_table[:context].eq(singular_name))
67
+ .where(tagging_table.name => { context: singular_name })
68
+ .where(tag_table.name => { name: tags })
69
+ .select(primary_key)
70
+
71
+ where("#{table_name}.#{primary_key} NOT IN (?)", subquery)
72
+ }
73
+
74
+ # Find records without any tags
75
+ scope "without_#{context}", lambda {
76
+ subquery = if setup.polymorphic
77
+ setup.tagging_class_name.constantize
78
+ .where(context: singular_name)
79
+ .where(taggable_type: name)
80
+ .select(:taggable_id)
81
+ else
82
+ setup.tagging_class_name.constantize
83
+ .where(context: singular_name)
84
+ .select(:taggable_id)
85
+ end
86
+
87
+ where("#{table_name}.#{primary_key} NOT IN (?)", subquery)
88
+ }
89
+
90
+ # Find records with exactly these tags
91
+ scope "with_exact_#{context}", lambda { |*tags|
92
+ tags = tags.flatten.compact.uniq
93
+
94
+ if tags.empty?
95
+ send("without_#{context}")
96
+ else
97
+ Arel::Nodes::NamedFunction.new(
98
+ 'COUNT',
99
+ [Arel::Nodes::NamedFunction.new('DISTINCT', [tag_table[:id]])]
100
+ )
101
+
102
+ # Build the query for records having exactly the tags
103
+ all_tags_query = select(arel_table[primary_key])
104
+ .joins("INNER JOIN #{tagging_table.name} ON #{tagging_table.name}.taggable_id = #{table_name}.#{primary_key}")
105
+ .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id")
106
+ .where("#{tagging_table.name}.context = ?", context.to_s.singularize)
107
+ .where("#{tag_table.name}.name IN (?)", tags)
108
+ .group(arel_table[primary_key])
109
+ .having("COUNT(DISTINCT #{tag_table.name}.id) = ?", tags.size)
110
+
111
+ # Build query for records with other tags
112
+ other_tags_query = select(arel_table[primary_key])
113
+ .joins("INNER JOIN #{tagging_table.name} ON #{tagging_table.name}.taggable_id = #{table_name}.#{primary_key}")
114
+ .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id")
115
+ .where("#{tagging_table.name}.context = ?", context.to_s.singularize)
116
+ .where("#{tag_table.name}.name NOT IN (?)", tags)
117
+
118
+ # Combine queries
119
+ where("#{table_name}.#{primary_key} IN (?)", all_tags_query)
120
+ .where("#{table_name}.#{primary_key} NOT IN (?)", other_tags_query)
121
+ end
122
+ }
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end