no_fly_list 0.7.4 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a4d0bb308f36212383e565010061a086173ddbd9105fad575193d7abca8ffa5
4
- data.tar.gz: 92772d14d0351c3a7a0d1e631d81590fb0a87e49460773cd68e78368ef55906b
3
+ metadata.gz: f6f9c09e56aee23105de3e334b059849897533057cbd53e48a1dc7436f693438
4
+ data.tar.gz: a76825bbc73c6ff0840cd2a362aaa30ecebba3cdd97c1f63ee9aa59d3bbfced0
5
5
  SHA512:
6
- metadata.gz: 793849ef8f48b2ced0c5547187fa02cc704b05bb6ebad05b65c2d7e21780ab6b2efa6349a97ef74af310ef49afa28c79506ee431f45b74613bc9038482ebf970
7
- data.tar.gz: 963dd7b6d64a7b159bc4085a6ea80f3641730a108dc820a91bb9201b8e636ac5705e07f4a561a6fa1089d377866ef9fbf0c55d2018796f1bd8a9750573584c85
6
+ metadata.gz: 44a42d785b563dd693515d30416a7eba3d27f506e432d33b48a8f0c182205d61cee306b8002bd49796c9022e4e69befe7806126b2e5b1cc559ac8f895aa9ed68
7
+ data.tar.gz: 5d33dcabce016e5eedda20f428b307a257ae3b27c34f6660ac89f2616f7cd63de88833c07a3442cd0ef6a21d93f8d563019b634c6656f20bd37376b48b57bc34
@@ -14,6 +14,8 @@ module NoFlyList
14
14
  source_root File.expand_path("templates", __dir__)
15
15
 
16
16
  argument :connection_name, type: :string, desc: "The name of the database connection", default: "primary"
17
+ class_option :scope, type: :string,
18
+ desc: "belongs_to association (e.g. entity) that keeps tags unique per scope"
17
19
 
18
20
  def copy_application_tag
19
21
  ensure_connection_exists
@@ -45,6 +47,15 @@ module NoFlyList
45
47
  klass&.name || "ApplicationRecord"
46
48
  end
47
49
 
50
+ # Column added to the tag table for --scope=NAME: NAME_id, or NAME when
51
+ # it already ends in _id.
52
+ def scope_column
53
+ scope = options[:scope]
54
+ return if scope.blank?
55
+
56
+ scope.end_with?("_id") ? scope : "#{scope}_id"
57
+ end
58
+
48
59
  def migration_version
49
60
  "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
50
61
  end
@@ -13,6 +13,8 @@ module NoFlyList
13
13
 
14
14
  class_option :database, type: :string, default: "primary",
15
15
  desc: "Use different database for migration"
16
+ class_option :scope, type: :string,
17
+ desc: "belongs_to association (e.g. entity) that keeps tags unique per scope"
16
18
 
17
19
  def self.default_generator_root
18
20
  File.dirname(__FILE__)
@@ -60,6 +62,18 @@ module NoFlyList
60
62
  "#{model_table_name.singularize}_taggings"
61
63
  end
62
64
 
65
+ # Column added to the tag table for --scope=NAME: the foreign key of the
66
+ # model's NAME association, else NAME_id, or NAME when it ends in _id.
67
+ def scope_column
68
+ scope = options[:scope]
69
+ return if scope.blank?
70
+
71
+ reflection = target_class.reflect_on_association(scope)
72
+ return reflection.foreign_key.to_s if reflection
73
+
74
+ scope.end_with?("_id") ? scope : "#{scope}_id"
75
+ end
76
+
63
77
  def migration_version
64
78
  "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
65
79
  end
@@ -1,7 +1,13 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def up
3
3
  create_table :application_tags do |t|
4
+ <% if scope_column -%>
5
+ t.column :<%= scope_column %>, :bigint, null: false # Change to :uuid if you are using UUIDs
6
+ t.string :name, null: false
7
+ t.index %i[<%= scope_column %> name], unique: true
8
+ <% else -%>
4
9
  t.string :name, null: false, index: { unique: true }
10
+ <% end -%>
5
11
  t.timestamp :created_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
6
12
  t.timestamp :updated_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
7
13
  end
@@ -1,6 +1,9 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
3
  create_table :<%= tag_table_name %>, id: :bigint do |t|
4
+ <% if scope_column -%>
5
+ t.column :<%= scope_column %>, :bigint, null: false # Change to :uuid if you are using UUIDs
6
+ <% end -%>
4
7
  t.string :name, null: false
5
8
  t.timestamp :created_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
6
9
  t.timestamp :updated_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
@@ -14,7 +17,11 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version
14
17
  t.timestamp :updated_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false
15
18
  end
16
19
 
20
+ <% if scope_column -%>
21
+ add_index :<%= tag_table_name %>, %i[<%= scope_column %> name], unique: true
22
+ <% else -%>
17
23
  add_index :<%= tag_table_name %>, :name, unique: true
24
+ <% end -%>
18
25
  add_index :<%= tagging_table_name %>, %i[taggable_id tag_id], unique: true
19
26
  add_foreign_key :<%= tagging_table_name %>, :<%= tag_table_name %>, column: :tag_id
20
27
  add_foreign_key :<%= tagging_table_name %>, :<%= model_table_name %>, column: :taggable_id
@@ -14,6 +14,7 @@ module NoFlyList
14
14
  context = context.to_sym
15
15
  tag_class_name = determine_tag_class_name(options)
16
16
  tagging_class_name = determine_tagging_class_name(options)
17
+ ensure_consistent_scope(context, tag_class_name, options[:scope])
17
18
 
18
19
  @tag_contexts[context] = {
19
20
  taggable_class: @taggable_class.to_s,
@@ -25,12 +26,25 @@ module NoFlyList
25
26
  limit: options.fetch(:limit, nil),
26
27
  case_sensitive: options.fetch(:case_sensitive, true),
27
28
  adapter: @adapter,
28
- counter_cache: options.fetch(:counter_cache, false)
29
+ counter_cache: options.fetch(:counter_cache, false),
30
+ scope: options[:scope]
29
31
  }
30
32
  end
31
33
 
32
34
  private
33
35
 
36
+ # Contexts that share a tag table must agree on its scope, otherwise an
37
+ # unscoped context would match tags across scopes.
38
+ def ensure_consistent_scope(context, tag_class_name, scope)
39
+ other = @tag_contexts.find do |name, config|
40
+ name != context && config[:tag_class_name] == tag_class_name && config[:scope] != scope
41
+ end
42
+ return unless other
43
+
44
+ raise ArgumentError, "NoFlyList: #{@taggable_class}##{context} uses scope #{scope.inspect} but " \
45
+ "#{other.first} uses scope #{other.last[:scope].inspect} on the same tag class #{tag_class_name}"
46
+ end
47
+
34
48
  def determine_adapter
35
49
  case taggable_class.connection.adapter_name.downcase
36
50
  when "postgresql"
@@ -249,7 +249,8 @@ module NoFlyList
249
249
  setup.context,
250
250
  transformer: setup.transformer,
251
251
  restrict_to_existing: setup.restrict_to_existing,
252
- limit: calculate_limit(setup.limit)
252
+ limit: calculate_limit(setup.limit),
253
+ scope_column: setup.scope_column
253
254
  )
254
255
  instance_variable_set(instance_variable_name, proxy)
255
256
  end
@@ -28,9 +28,9 @@ module NoFlyList
28
28
  .project(arel_table[primary_key])
29
29
  .distinct
30
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]))
31
+ .join(tag_table).on(Query.tag_join_condition(setup, arel_table, tagging_table, tag_table))
32
32
  .where(tagging_table[:context].eq(singular_name))
33
- .where(tag_table[:name].in(tags))
33
+ .where(Query.name_in(setup, tag_table, tags))
34
34
 
35
35
  where(arel_table[primary_key].in(query))
36
36
  }
@@ -41,18 +41,18 @@ module NoFlyList
41
41
 
42
42
  count_function = Arel::Nodes::NamedFunction.new(
43
43
  "COUNT",
44
- [ Arel::Nodes::NamedFunction.new("DISTINCT", [ tag_table[:name] ]) ]
44
+ [ Arel::Nodes::NamedFunction.new("DISTINCT", [ Query.name_column(setup, tag_table) ]) ]
45
45
  )
46
46
 
47
47
  query = Arel::SelectManager.new(self)
48
48
  .from(table_name)
49
49
  .project(arel_table[primary_key])
50
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]))
51
+ .join(tag_table).on(Query.tag_join_condition(setup, arel_table, tagging_table, tag_table))
52
52
  .where(tagging_table[:context].eq(singular_name))
53
- .where(tag_table[:name].in(tags))
53
+ .where(Query.name_in(setup, tag_table, tags))
54
54
  .group(arel_table[primary_key])
55
- .having(count_function.eq(tags.size))
55
+ .having(count_function.eq(Query.distinct_names(setup, tags).size))
56
56
 
57
57
  where(arel_table[primary_key].in(query))
58
58
  }
@@ -62,17 +62,13 @@ module NoFlyList
62
62
  tags = tags.flatten.compact.uniq
63
63
  return all if tags.empty?
64
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)
65
+ where(arel_table[primary_key].not_in(Query.tagged_ids(self, setup, tags)))
72
66
  }
73
67
 
74
68
  # Find records without any tags
75
69
  scope "without_#{context}", lambda {
70
+ return where(arel_table[primary_key].not_in(Query.tagged_ids(self, setup))) if setup.scope_column
71
+
76
72
  subquery = if setup.polymorphic
77
73
  setup.tagging_class_name.constantize
78
74
  .where(context: singular_name)
@@ -102,18 +98,18 @@ module NoFlyList
102
98
  # Build the query for records having exactly the tags
103
99
  all_tags_query = select(arel_table[primary_key])
104
100
  .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")
101
+ .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id#{Query.scope_join_sql(setup, tag_table.name, table_name)}")
106
102
  .where("#{tagging_table.name}.context = ?", context.to_s.singularize)
107
- .where("#{tag_table.name}.name IN (?)", tags)
103
+ .where(*Query.name_in_sql(setup, tag_table, tags))
108
104
  .group(arel_table[primary_key])
109
- .having("COUNT(DISTINCT #{tag_table.name}.id) = ?", tags.size)
105
+ .having("#{Query.distinct_tag_count_sql(setup, tag_table)} = ?", Query.distinct_names(setup, tags).size)
110
106
 
111
107
  # Build query for records with other tags
112
108
  other_tags_query = select(arel_table[primary_key])
113
109
  .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")
110
+ .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id#{Query.scope_join_sql(setup, tag_table.name, table_name)}")
115
111
  .where("#{tagging_table.name}.context = ?", context.to_s.singularize)
116
- .where("#{tag_table.name}.name NOT IN (?)", tags)
112
+ .where(*Query.name_in_sql(setup, tag_table, tags, negate: true))
117
113
 
118
114
  # Combine queries
119
115
  where("#{table_name}.#{primary_key} IN (?)", all_tags_query)
@@ -28,9 +28,9 @@ module NoFlyList
28
28
  .project(arel_table[primary_key])
29
29
  .distinct
30
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]))
31
+ .join(tag_table).on(Query.tag_join_condition(setup, arel_table, tagging_table, tag_table))
32
32
  .where(tagging_table[:context].eq(singular_name))
33
- .where(tag_table[:name].in(tags))
33
+ .where(Query.name_in(setup, tag_table, tags))
34
34
 
35
35
  where(arel_table[primary_key].in(query))
36
36
  }
@@ -41,18 +41,18 @@ module NoFlyList
41
41
 
42
42
  count_function = Arel::Nodes::NamedFunction.new(
43
43
  "COUNT",
44
- [ Arel::Nodes::NamedFunction.new("DISTINCT", [ tag_table[:name] ]) ]
44
+ [ Arel::Nodes::NamedFunction.new("DISTINCT", [ Query.name_column(setup, tag_table) ]) ]
45
45
  )
46
46
 
47
47
  query = Arel::SelectManager.new(self)
48
48
  .from(table_name)
49
49
  .project(arel_table[primary_key])
50
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]))
51
+ .join(tag_table).on(Query.tag_join_condition(setup, arel_table, tagging_table, tag_table))
52
52
  .where(tagging_table[:context].eq(singular_name))
53
- .where(tag_table[:name].in(tags))
53
+ .where(Query.name_in(setup, tag_table, tags))
54
54
  .group(arel_table[primary_key])
55
- .having(count_function.eq(tags.size))
55
+ .having(count_function.eq(Query.distinct_names(setup, tags).size))
56
56
 
57
57
  where(arel_table[primary_key].in(query))
58
58
  }
@@ -62,17 +62,13 @@ module NoFlyList
62
62
  tags = tags.flatten.compact.uniq
63
63
  return all if tags.empty?
64
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.not(primary_key => subquery)
65
+ where(arel_table[primary_key].not_in(Query.tagged_ids(self, setup, tags)))
72
66
  }
73
67
 
74
68
  # Find records without any tags
75
69
  scope "without_#{context}", lambda {
70
+ return where(arel_table[primary_key].not_in(Query.tagged_ids(self, setup))) if setup.scope_column
71
+
76
72
  subquery = if setup.polymorphic
77
73
  setup.tagging_class_name.constantize
78
74
  .where(context: singular_name)
@@ -102,18 +98,18 @@ module NoFlyList
102
98
  # Build the query for records having exactly the tags
103
99
  all_tags_query = select(arel_table[primary_key])
104
100
  .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")
101
+ .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id#{Query.scope_join_sql(setup, tag_table.name, table_name)}")
106
102
  .where("#{tagging_table.name}.context = ?", context.to_s.singularize)
107
- .where("#{tag_table.name}.name IN (?)", tags)
103
+ .where(*Query.name_in_sql(setup, tag_table, tags))
108
104
  .group(arel_table[primary_key])
109
- .having("COUNT(DISTINCT #{tag_table.name}.id) = ?", tags.size)
105
+ .having("#{Query.distinct_tag_count_sql(setup, tag_table)} = ?", Query.distinct_names(setup, tags).size)
110
106
 
111
107
  # Build query for records with other tags
112
108
  other_tags_query = select(arel_table[primary_key])
113
109
  .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")
110
+ .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id#{Query.scope_join_sql(setup, tag_table.name, table_name)}")
115
111
  .where("#{tagging_table.name}.context = ?", context.to_s.singularize)
116
- .where("#{tag_table.name}.name NOT IN (?)", tags)
112
+ .where(*Query.name_in_sql(setup, tag_table, tags, negate: true))
117
113
 
118
114
  # Combine queries
119
115
  where("#{table_name}.#{primary_key} IN (?)", all_tags_query)
@@ -28,9 +28,9 @@ module NoFlyList
28
28
  .project(arel_table[primary_key])
29
29
  .distinct
30
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]))
31
+ .join(tag_table).on(Query.tag_join_condition(setup, arel_table, tagging_table, tag_table))
32
32
  .where(tagging_table[:context].eq(singular_name))
33
- .where(tag_table[:name].in(tags))
33
+ .where(Query.name_in(setup, tag_table, tags))
34
34
 
35
35
  where(arel_table[primary_key].in(query))
36
36
  }
@@ -41,18 +41,18 @@ module NoFlyList
41
41
 
42
42
  count_function = Arel::Nodes::NamedFunction.new(
43
43
  "COUNT",
44
- [ Arel::Nodes::NamedFunction.new("DISTINCT", [ tag_table[:name] ]) ]
44
+ [ Arel::Nodes::NamedFunction.new("DISTINCT", [ Query.name_column(setup, tag_table) ]) ]
45
45
  )
46
46
 
47
47
  query = Arel::SelectManager.new(self)
48
48
  .from(table_name)
49
49
  .project(arel_table[primary_key])
50
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]))
51
+ .join(tag_table).on(Query.tag_join_condition(setup, arel_table, tagging_table, tag_table))
52
52
  .where(tagging_table[:context].eq(singular_name))
53
- .where(tag_table[:name].in(tags))
53
+ .where(Query.name_in(setup, tag_table, tags))
54
54
  .group(arel_table[primary_key])
55
- .having(count_function.eq(tags.size))
55
+ .having(count_function.eq(Query.distinct_names(setup, tags).size))
56
56
 
57
57
  where(arel_table[primary_key].in(query))
58
58
  }
@@ -62,19 +62,13 @@ module NoFlyList
62
62
  tags = tags.flatten.compact.uniq
63
63
  return all if tags.empty?
64
64
 
65
- # Build dynamic joins
66
- tagged_ids = distinct
67
- .joins("INNER JOIN #{tagging_table} ON #{tagging_table}.taggable_id = #{table_name}.id")
68
- .joins("INNER JOIN #{context} ON #{context}.id = #{tagging_table}.tag_id")
69
- .where("#{context}.name IN (?)", tags)
70
- .pluck("#{table_name}.id")
71
-
72
- # Handle empty tagged_ids explicitly for SQLite compatibility
73
- where("#{table_name}.id NOT IN (?)", tagged_ids.present? ? tagged_ids : [ -1 ])
65
+ where(arel_table[primary_key].not_in(Query.tagged_ids(self, setup, tags)))
74
66
  }
75
67
 
76
68
  # Find records without any tags
77
69
  scope "without_#{context}", lambda {
70
+ return where(arel_table[primary_key].not_in(Query.tagged_ids(self, setup))) if setup.scope_column
71
+
78
72
  subquery = if setup.polymorphic
79
73
  setup.tagging_class_name.constantize
80
74
  .where(context: singular_name, taggable_type: name)
@@ -102,53 +96,18 @@ module NoFlyList
102
96
  # Build the query for records having exactly the tags
103
97
  all_tags_query = select(arel_table[primary_key])
104
98
  .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 = ?", singular_name)
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 = ?", singular_name)
116
- .where("#{tag_table.name}.name NOT IN (?)", tags)
117
-
118
- # Combine queries using subqueries
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
-
124
- # Add tag counts
125
- # Find records with exactly these tags
126
- scope "with_exact_#{context}", lambda { |*tags|
127
- tags = tags.flatten.compact.uniq
128
-
129
- if tags.empty?
130
- send("without_#{context}")
131
- else
132
- Arel::Nodes::NamedFunction.new(
133
- "COUNT",
134
- [ Arel::Nodes::NamedFunction.new("DISTINCT", [ tag_table[:id] ]) ]
135
- )
136
-
137
- # Build the query for records having exactly the tags
138
- all_tags_query = select(arel_table[primary_key])
139
- .joins("INNER JOIN #{tagging_table.name} ON #{tagging_table.name}.taggable_id = #{table_name}.#{primary_key}")
140
- .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id")
99
+ .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id#{Query.scope_join_sql(setup, tag_table.name, table_name)}")
141
100
  .where("#{tagging_table.name}.context = ?", context.to_s.singularize)
142
- .where("#{tag_table.name}.name IN (?)", tags)
101
+ .where(*Query.name_in_sql(setup, tag_table, tags))
143
102
  .group(arel_table[primary_key])
144
- .having("COUNT(DISTINCT #{tag_table.name}.id) = ?", tags.size)
103
+ .having("#{Query.distinct_tag_count_sql(setup, tag_table)} = ?", Query.distinct_names(setup, tags).size)
145
104
 
146
105
  # Build query for records with other tags
147
106
  other_tags_query = select(arel_table[primary_key])
148
107
  .joins("INNER JOIN #{tagging_table.name} ON #{tagging_table.name}.taggable_id = #{table_name}.#{primary_key}")
149
- .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id")
108
+ .joins("INNER JOIN #{tag_table.name} ON #{tag_table.name}.id = #{tagging_table.name}.tag_id#{Query.scope_join_sql(setup, tag_table.name, table_name)}")
150
109
  .where("#{tagging_table.name}.context = ?", context.to_s.singularize)
151
- .where("#{tag_table.name}.name NOT IN (?)", tags)
110
+ .where(*Query.name_in_sql(setup, tag_table, tags, negate: true))
152
111
 
153
112
  # Combine queries
154
113
  where("#{table_name}.#{primary_key} IN (?)", all_tags_query)
@@ -22,18 +22,89 @@ module NoFlyList
22
22
  end
23
23
  end
24
24
 
25
- module BaseStrategy
26
- module_function
25
+ # Builds a subquery selecting the ids of taggable rows that have at least
26
+ # one tagging in the setup's context, limited to tags named +names+ when
27
+ # given.
28
+ # @param relation [ActiveRecord::Relation] Taggable relation the scope runs on
29
+ # @param setup [TagSetup] Tag setup configuration
30
+ # @param names [Array<String>, nil] Tag names to match
31
+ # @return [Arel::SelectManager] Subquery projecting taggable ids
32
+ def tagged_ids(relation, setup, names = nil)
33
+ taggable_table = relation.arel_table
34
+ tagging_table = setup.tagging_class_name.constantize.arel_table
35
+ tag_table = setup.tag_class_name.constantize.arel_table
27
36
 
28
- # Performs case-insensitive column comparison
29
- # @param table [Arel::Table] Database table
30
- # @param column [Symbol] Column name
31
- # @param values [Array<String>] Values to compare
32
- # @return [Arel::Node] Query node
33
- # @abstract
34
- def case_insensitive_where(table, column, values)
35
- raise NotImplementedError
37
+ query = Arel::SelectManager.new(relation)
38
+ .from(relation.table_name)
39
+ .project(taggable_table[relation.primary_key])
40
+ .join(tagging_table).on(tagging_table[:taggable_id].eq(taggable_table[relation.primary_key]))
41
+ .join(tag_table).on(tag_join_condition(setup, taggable_table, tagging_table, tag_table))
42
+ .where(tagging_table[:context].eq(setup.context.to_s.singularize))
43
+ query.where(tagging_table[:taggable_type].eq(relation.name)) if setup.polymorphic
44
+ query.where(name_in(setup, tag_table, names)) if names
45
+ query
46
+ end
47
+
48
+ # Join condition from a tagging to its tag. Scoped tags must also share
49
+ # the taggable row's scope value.
50
+ # @return [Arel::Nodes::Node] Join condition
51
+ def tag_join_condition(setup, taggable_table, tagging_table, tag_table)
52
+ condition = tag_table[:id].eq(tagging_table[:tag_id])
53
+ column = setup.scope_column
54
+ column ? condition.and(tag_table[column].eq(taggable_table[column])) : condition
55
+ end
56
+
57
+ # SQL counterpart of #tag_join_condition, appended to a raw tag join.
58
+ # @return [String] Extra join condition, empty for unscoped tags
59
+ def scope_join_sql(setup, tag_table_name, taggable_table_name)
60
+ column = setup.scope_column
61
+ column ? " AND #{tag_table_name}.#{column} = #{taggable_table_name}.#{column}" : ""
62
+ end
63
+
64
+ # Tag name as compared by the context: the column itself, or LOWER(name)
65
+ # when the context is case insensitive.
66
+ # @return [Arel::Nodes::Node] Name expression
67
+ def name_column(setup, tag_table)
68
+ setup.case_sensitive ? tag_table[:name] : tag_table[:name].lower
69
+ end
70
+
71
+ # Condition matching tags named +names+. Case insensitive contexts
72
+ # compare LOWER(name) with LOWER of each value.
73
+ # @return [Arel::Nodes::Node] Name condition
74
+ def name_in(setup, tag_table, names)
75
+ return tag_table[:name].in(names) if setup.case_sensitive
76
+
77
+ name_column(setup, tag_table).in(names.map { |name| Arel::Nodes::NamedFunction.new("LOWER", [ Arel::Nodes.build_quoted(name) ]) })
78
+ end
79
+
80
+ # +where+ arguments for the raw SQL queries, matching (or with
81
+ # +negate+, excluding) tags named +names+.
82
+ # @return [Array] Arguments for ActiveRecord::QueryMethods#where
83
+ def name_in_sql(setup, tag_table, names, negate: false)
84
+ if setup.case_sensitive
85
+ [ "#{tag_table.name}.name #{negate ? 'NOT IN' : 'IN'} (?)", names ]
86
+ else
87
+ condition = name_in(setup, tag_table, names)
88
+ [ negate ? condition.not : condition ]
36
89
  end
90
+ end
91
+
92
+ # Requested names without duplicates, ignoring case when the context is
93
+ # case insensitive, so counts compare against distinct names.
94
+ # @return [Array<String>] Distinct names
95
+ def distinct_names(setup, names)
96
+ setup.case_sensitive ? names : names.uniq(&:downcase)
97
+ end
98
+
99
+ # SQL counting a row's distinct matching tags: by id, or by LOWER(name)
100
+ # when the context is case insensitive.
101
+ # @return [String] Aggregate expression
102
+ def distinct_tag_count_sql(setup, tag_table)
103
+ setup.case_sensitive ? "COUNT(DISTINCT #{tag_table.name}.id)" : "COUNT(DISTINCT LOWER(#{tag_table.name}.name))"
104
+ end
105
+
106
+ module BaseStrategy
107
+ module_function
37
108
 
38
109
  # Defines database-specific query methods
39
110
  # @abstract
@@ -34,6 +34,12 @@ module NoFlyList
34
34
  # @return [Symbol] Database adapter type (:postgresql, :mysql, :sqlite)
35
35
  attr_reader :adapter
36
36
 
37
+ # @return [Symbol, nil] belongs_to association (or column) that partitions tags
38
+ attr_reader :scope
39
+
40
+ # @return [Boolean] Whether tag names are compared case sensitively
41
+ attr_reader :case_sensitive
42
+
37
43
  # Creates new tag setup configuration
38
44
  # @param taggable_klass [Class] Model to configure
39
45
  # @param context [Symbol] Tag context name
@@ -50,6 +56,26 @@ module NoFlyList
50
56
  @tag_class_name = determine_tag_class_name(taggable_klass, options)
51
57
  @tagging_class_name = determine_tagging_class_name(taggable_klass, options)
52
58
  @adapter = determine_adapter
59
+ @scope = options[:scope]
60
+ @case_sensitive = options.fetch(:case_sensitive, true)
61
+ end
62
+
63
+ # Column shared by the taggable and tag tables that keeps tags unique per
64
+ # scope: the foreign key of the +scope+ association, or +scope+ itself
65
+ # when no such association exists. Resolved on first use so +has_tags+
66
+ # may be declared before the association.
67
+ # @return [String, nil] Scope column name, or nil for unscoped tags
68
+ def scope_column
69
+ return unless scope
70
+
71
+ @scope_column ||= begin
72
+ reflection = taggable_klass.reflect_on_association(scope)
73
+ if reflection&.polymorphic?
74
+ raise ArgumentError, "NoFlyList: scope #{scope.inspect} on #{taggable_klass.name} is a polymorphic association, which is not supported"
75
+ end
76
+
77
+ reflection ? reflection.foreign_key.to_s : scope.to_s
78
+ end
53
79
  end
54
80
 
55
81
  private
@@ -18,16 +18,19 @@ module NoFlyList
18
18
  # @param transformer [Class] Class for transforming tag strings
19
19
  # @param restrict_to_existing [Boolean] Only allow existing tags
20
20
  # @param limit [Integer, nil] Maximum number of tags allowed
21
+ # @param scope_column [String, nil] Column that keeps tags unique per scope
21
22
  def initialize(model, tag_model, context,
22
23
  transformer: "ApplicationTagTransformer",
23
24
  restrict_to_existing: false,
24
- limit: nil)
25
+ limit: nil,
26
+ scope_column: nil)
25
27
  @model = model
26
28
  @tag_model = tag_model
27
29
  @context = context
28
30
  @transformer = resolve_transformer(transformer)
29
31
  @restrict_to_existing = restrict_to_existing
30
32
  @limit = limit
33
+ @scope_column = scope_column
31
34
  @pending_changes = nil # Use nil to indicate no changes yet
32
35
  @clear_operation = false
33
36
  end
@@ -105,11 +108,9 @@ module NoFlyList
105
108
  # Update counter
106
109
  model.update_column("#{@context}_count", 0) if setup[:counter_cache]
107
110
 
108
- # Create new tags
109
- pending_list.each do |tag_name|
110
- tag = find_or_create_tag(tag_name)
111
- next unless tag
112
-
111
+ # Create new tags. Names differing only in case resolve to the same
112
+ # tag when matching is case insensitive, so tag it once.
113
+ pending_list.filter_map { |tag_name| find_or_create_tag(tag_name) }.uniq.each do |tag|
113
114
  attributes = {
114
115
  tag: tag,
115
116
  context: @context.to_s.singularize
@@ -374,8 +375,13 @@ module NoFlyList
374
375
 
375
376
  # Transform tags to lowercase for comparison
376
377
  normalized_changes = pending_list.map(&:downcase)
377
- existing_tags = @tag_model.where("LOWER(name) IN (?)", normalized_changes).pluck(:name)
378
- missing_tags = pending_list - existing_tags
378
+ existing_tags = tag_scope.where("LOWER(name) IN (?)", normalized_changes).pluck(:name)
379
+ missing_tags = if setup[:case_sensitive]
380
+ pending_list - existing_tags
381
+ else
382
+ existing = existing_tags.map(&:downcase)
383
+ pending_list.reject { |tag_name| existing.include?(tag_name.downcase) }
384
+ end
379
385
 
380
386
  return unless missing_tags.any?
381
387
 
@@ -394,13 +400,28 @@ module NoFlyList
394
400
  end
395
401
 
396
402
  def find_or_create_tag(tag_name)
397
- if @restrict_to_existing
398
- @tag_model.find_by(name: tag_name)
403
+ tag = find_tag(tag_name)
404
+ return tag if tag || @restrict_to_existing
405
+
406
+ tag_scope.create(name: tag_name)
407
+ end
408
+
409
+ def find_tag(tag_name)
410
+ if setup[:case_sensitive]
411
+ tag_scope.find_by(name: tag_name)
399
412
  else
400
- @tag_model.find_or_create_by(name: tag_name)
413
+ tag_scope.find_by(@tag_model.arel_table[:name].lower.eq(tag_name.downcase))
401
414
  end
402
415
  end
403
416
 
417
+ # Tags available to the model: all tags, or only those sharing the
418
+ # model's scope value when tags are scoped.
419
+ def tag_scope
420
+ return @tag_model unless @scope_column
421
+
422
+ @tag_model.where(@scope_column => @model[@scope_column])
423
+ end
424
+
404
425
  # Helper method to get the list of tags that should be saved
405
426
  def pending_list
406
427
  if @clear_operation
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NoFlyList
4
- VERSION = "0.7.4"
4
+ VERSION = "0.8.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: no_fly_list
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.4
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.2'
18
+ version: '8.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '7.2'
25
+ version: '8.1'
26
26
  description: A flexible, high-performance tagging system for Rails applications with
27
27
  support for polymorphic tags, custom transformers, and database-specific optimizations
28
28
  email:
@@ -80,14 +80,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
80
80
  requirements:
81
81
  - - ">="
82
82
  - !ruby/object:Gem::Version
83
- version: 3.2.0
83
+ version: 4.0.0
84
84
  required_rubygems_version: !ruby/object:Gem::Requirement
85
85
  requirements:
86
86
  - - ">="
87
87
  - !ruby/object:Gem::Version
88
88
  version: '0'
89
89
  requirements: []
90
- rubygems_version: 3.6.9
90
+ rubygems_version: 4.0.20
91
91
  specification_version: 4
92
- summary: Modern tagging system for Rails 7.2+ applications
92
+ summary: Modern tagging system for Rails 8.1+ applications
93
93
  test_files: []