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 +4 -4
- data/lib/generators/no_fly_list/install_generator.rb +11 -0
- data/lib/generators/no_fly_list/tagging_generator.rb +14 -0
- data/lib/generators/no_fly_list/templates/create_application_tagging_table.rb.erb +6 -0
- data/lib/generators/no_fly_list/templates/create_tagging_table.rb.erb +7 -0
- data/lib/no_fly_list/taggable_record/config.rb +15 -1
- data/lib/no_fly_list/taggable_record/configuration.rb +2 -1
- data/lib/no_fly_list/taggable_record/query/mysql_strategy.rb +14 -18
- data/lib/no_fly_list/taggable_record/query/postgresql_strategy.rb +14 -18
- data/lib/no_fly_list/taggable_record/query/sqlite_strategy.rb +14 -55
- data/lib/no_fly_list/taggable_record/query.rb +81 -10
- data/lib/no_fly_list/taggable_record/tag_setup.rb +26 -0
- data/lib/no_fly_list/tagging_proxy.rb +32 -11
- data/lib/no_fly_list/version.rb +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6f9c09e56aee23105de3e334b059849897533057cbd53e48a1dc7436f693438
|
|
4
|
+
data.tar.gz: a76825bbc73c6ff0840cd2a362aaa30ecebba3cdd97c1f63ee9aa59d3bbfced0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
103
|
+
.where(*Query.name_in_sql(setup, tag_table, tags))
|
|
108
104
|
.group(arel_table[primary_key])
|
|
109
|
-
.having("
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
103
|
+
.where(*Query.name_in_sql(setup, tag_table, tags))
|
|
108
104
|
.group(arel_table[primary_key])
|
|
109
|
-
.having("
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
101
|
+
.where(*Query.name_in_sql(setup, tag_table, tags))
|
|
143
102
|
.group(arel_table[primary_key])
|
|
144
|
-
.having("
|
|
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(
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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 =
|
|
378
|
-
missing_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
|
-
|
|
398
|
-
|
|
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.
|
|
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
|
data/lib/no_fly_list/version.rb
CHANGED
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.
|
|
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: '
|
|
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: '
|
|
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:
|
|
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:
|
|
90
|
+
rubygems_version: 4.0.20
|
|
91
91
|
specification_version: 4
|
|
92
|
-
summary: Modern tagging system for Rails
|
|
92
|
+
summary: Modern tagging system for Rails 8.1+ applications
|
|
93
93
|
test_files: []
|