no_fly_list 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fbe92a4791b7cf24e4a2ca876b1c7f62cd5a37d653cdcad45f752a393c04f16
4
- data.tar.gz: 00c74e1f82fea31e30f517ff102d5fc41c714392c73b05c841737837b521b370
3
+ metadata.gz: a736dbad67c9674eb172dc3633226f261d4d6bfaba47e06b3c539f98dfbfc439
4
+ data.tar.gz: 378ddddcfc84f69bc667df66195a87298705f6fe4ebd3067bce71d295a7eb309
5
5
  SHA512:
6
- metadata.gz: 590cf033d9ed9fa653341b8aedb2e731244e45505b0945147e7da1ca0fac264e7f161cd5bb5608e95c5567f17fdba5837338e851e1dc76d707b8bfef379974ce
7
- data.tar.gz: b55be12266e3e9ee2deeb8fa82aa4e405762ad8a2f3bb6643933f30dab10a918b5add73ffb7a29b17f741be404afc952027e53e85c4253f8deee4d6630b3966b
6
+ metadata.gz: 2f5dada9474e4defdd42556d2ab049a85774d9d7e75966c75ceac76ec9c1a793873f13b06281a12089264aa64de94268db2b5e1724d3bccf2e8c9c79062ceba9
7
+ data.tar.gz: 888516a4c63cc367b976e7c0626efef8d65cb06d9d3d207f6100a0a6cb3590528f1f6f3f44b7fb697cb8bf3271f90d23878725615dd1304f10d74fa0fc313198
@@ -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
@@ -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