make_taggable 1.2.1 → 1.3.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/CHANGELOG.md +32 -0
- data/docs/querying.md +20 -5
- data/lib/make_taggable/taggable/core.rb +2 -2
- data/lib/make_taggable/taggable/tagged_with_query/all_tags_query.rb +47 -36
- data/lib/make_taggable/taggable/tagged_with_query/any_tags_query.rb +9 -38
- data/lib/make_taggable/taggable/tagged_with_query/exclude_tags_query.rb +11 -4
- data/lib/make_taggable/taggable/tagged_with_query/query_base.rb +40 -0
- data/lib/make_taggable/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b11d21dbd46411b5b827d1b70cb44bcb641259804a32fa221597e56e02aa906
|
|
4
|
+
data.tar.gz: 3ebf2e595313e19cc88e28ab7541c40bae5fd1c702a09b8f60873fb68605bfcd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 543f460b5117fd847d6f2ed9a7bc694aece6306b3268912710c058cf6bb48275932d1a06ee0adf867492c175ca858cf84794db4238e52b8bad4f2f8ff71fdbc2
|
|
7
|
+
data.tar.gz: 9983b5c3084235e612097a52bda93c4bd48799e4c29a8c30b354d2ad8d24bc07db2ec99d4077956e193dfa5c175e21198f3238d912882a7015887d72438ea1a2
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
6
6
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.3.0] - 2026-08-23
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **`tagged_with` no longer joins the taggings table.** It tests for each tag with an `EXISTS`
|
|
13
|
+
subquery instead, which is what stops a record being returned once per matching tagging. A tag
|
|
14
|
+
applied in two contexts, or a `:wild` pattern matching two of a record's tags, returned that
|
|
15
|
+
record twice; `.count` disagreed with the number of records, and pagination pages ran short.
|
|
16
|
+
|
|
17
|
+
The consequence for callers is that a `taggings` column is no longer in scope on the relation, so
|
|
18
|
+
`tagged_with("x").order("taggings.created_at")` or a `group` on a taggings column now needs an
|
|
19
|
+
explicit `.joins(:taggings)`. See [docs/querying.md](docs/querying.md).
|
|
20
|
+
|
|
21
|
+
Matching a dozen tags now produces a dozen correlated subqueries rather than a dozen joins.
|
|
22
|
+
`:match_all` is unchanged and keeps its join.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `tagged_with(..., any: true)` forced `SELECT taggable_models.*` onto the relation. That made
|
|
27
|
+
`.count` emit `COUNT("table".*)`, which no adapter accepts, and left a caller's own `select`
|
|
28
|
+
appended after the star rather than replacing it -- so every column came back regardless, and the
|
|
29
|
+
relation could not be used inside a `merge`. The strategy filters with an `EXISTS` subquery and
|
|
30
|
+
joins nothing, so Active Record's default select list was already right.
|
|
31
|
+
|
|
32
|
+
- `:order_by_matching_tag_count` raised on the default all-tags path, and the expression behind it
|
|
33
|
+
was invalid SQL that would have ordered nothing even where it parsed. Both strategies now share
|
|
34
|
+
the correlated count the `:any` path has always used, so the option orders correctly on either.
|
|
35
|
+
It still has no effect alongside `:match_all`.
|
|
36
|
+
|
|
37
|
+
- `tagged_with(..., exclude: true)` ignored `:start_at` and `:end_at`, excluding records on the
|
|
38
|
+
strength of taggings from outside the window entirely.
|
|
39
|
+
|
|
8
40
|
## [1.2.1] - 2026-08-23
|
|
9
41
|
|
|
10
42
|
### Fixed
|
data/docs/querying.md
CHANGED
|
@@ -30,9 +30,9 @@ empty relation rather than every record — worth knowing when the tags come fro
|
|
|
30
30
|
| `:wild` | Match tags *containing* the given text, i.e. `%sci%` |
|
|
31
31
|
| `:on` | Restrict to one context. Honoured by every option, `:exclude` included |
|
|
32
32
|
| `:owned_by` | Restrict to tags applied by one tagger |
|
|
33
|
-
| `:order_by_matching_tag_count` |
|
|
34
|
-
| `:start_at` | Only tags applied after this time |
|
|
35
|
-
| `:end_at` | Only tags applied before this time |
|
|
33
|
+
| `:order_by_matching_tag_count` | Order by how many matching taggings a record has, most first. No effect with `:match_all` |
|
|
34
|
+
| `:start_at` | Only tags applied after this time. Honoured by every option, `:exclude` included |
|
|
35
|
+
| `:end_at` | Only tags applied before this time. Honoured by every option, `:exclude` included |
|
|
36
36
|
|
|
37
37
|
An empty tag list means "nothing matches" for the matching options and "nothing is ruled out" for
|
|
38
38
|
`:exclude`, so the two always partition the scope between them:
|
|
@@ -162,10 +162,25 @@ MakeTaggable::Tag.for_context(:skills) # used in this context, on any model
|
|
|
162
162
|
query time. If you set `MakeTaggable.tags_counter = false` that counter is not maintained and both
|
|
163
163
|
scopes will be wrong.
|
|
164
164
|
|
|
165
|
+
## Grouping and ordering by tagging columns
|
|
166
|
+
|
|
167
|
+
`tagged_with` does not join the taggings table, so a `taggings` column is not in scope on the
|
|
168
|
+
relation it returns. Join it yourself when you need one:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
Book.tagged_with("sci-fi").joins(:taggings).group("taggings.context").count
|
|
172
|
+
Book.tagged_with("sci-fi").joins(:taggings).order("taggings.created_at desc")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Note that joining reintroduces one row per tagging, which is exactly what `tagged_with` avoids on
|
|
176
|
+
its own — add `.distinct` if you want records back rather than matches.
|
|
177
|
+
|
|
165
178
|
## Performance notes
|
|
166
179
|
|
|
167
|
-
- `tagged_with`
|
|
168
|
-
|
|
180
|
+
- `tagged_with` tests for each tag with an `EXISTS` subquery, one per tag, and joins nothing. That
|
|
181
|
+
is what stops a record being returned once per matching tagging. Matching a dozen tags produces a
|
|
182
|
+
dozen correlated subqueries rather than a dozen joins, which most planners handle better, but it
|
|
183
|
+
is still worth reaching for `any: true` where the semantics allow it.
|
|
169
184
|
- `:order_by_matching_tag_count` adds a correlated subquery to the `ORDER BY`. It is fine for a
|
|
170
185
|
page of results and expensive across a whole table.
|
|
171
186
|
- `all_tag_counts` joins the taggables to count them. Reach for `all_tags` when the counts are not
|
|
@@ -123,8 +123,8 @@ module MakeTaggable::Taggable
|
|
|
123
123
|
# @option options [TrueClass, FalseClass] :exclude match records carrying none of the tags
|
|
124
124
|
# @option options [TrueClass, FalseClass] :match_all match records carrying only these tags
|
|
125
125
|
# @option options [TrueClass, FalseClass] :wild match tags containing the given text
|
|
126
|
-
# @option options [TrueClass, FalseClass] :order_by_matching_tag_count
|
|
127
|
-
#
|
|
126
|
+
# @option options [TrueClass, FalseClass] :order_by_matching_tag_count order by how many
|
|
127
|
+
# matching taggings a record has, most first. No effect alongside `:match_all`
|
|
128
128
|
# @option options [ActiveRecord::Base] :owned_by only tags applied by this tagger
|
|
129
129
|
# @option options [Symbol, String] :on only tags applied in this context
|
|
130
130
|
# @option options [Time, Date] :start_at only tags applied after this time
|
|
@@ -11,63 +11,72 @@ module MakeTaggable::Taggable::TaggedWithQuery
|
|
|
11
11
|
# @return [ActiveRecord::Relation]
|
|
12
12
|
#
|
|
13
13
|
def build
|
|
14
|
-
taggable_model.joins(
|
|
14
|
+
taggable_model.joins(match_all_join)
|
|
15
|
+
.where(carries_every_tag)
|
|
15
16
|
.group(by_taggable)
|
|
16
17
|
.having(tags_that_matches_count)
|
|
17
|
-
.order(order_conditions)
|
|
18
|
+
.order(Arel.sql(order_conditions))
|
|
18
19
|
.readonly(false)
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
private
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if options[:match_all].present?
|
|
34
|
-
arel_join = arel_join
|
|
35
|
-
.join(tagging_arel_table, Arel::Nodes::OuterJoin)
|
|
36
|
-
.on(
|
|
37
|
-
match_all_on_conditions
|
|
38
|
-
)
|
|
39
|
-
end
|
|
24
|
+
# One EXISTS test per tag, rather than one join per tag.
|
|
25
|
+
#
|
|
26
|
+
# A join multiplies the result: a record is returned once for every tagging
|
|
27
|
+
# that satisfies it, so a tag applied in two contexts, or a wild pattern
|
|
28
|
+
# matching two of a record's tags, returned that record twice. EXISTS asks
|
|
29
|
+
# the question the query is actually asking -- does this record carry the
|
|
30
|
+
# tag -- and answers it once.
|
|
31
|
+
def carries_every_tag
|
|
32
|
+
tag_list.map { |tag| taggings_for(tag).exists }.inject(:and)
|
|
33
|
+
end
|
|
40
34
|
|
|
41
|
-
|
|
35
|
+
def taggings_for(tag)
|
|
36
|
+
tagging_arel_table
|
|
37
|
+
.project(Arel.star)
|
|
38
|
+
.where(tag_conditions(tag))
|
|
42
39
|
end
|
|
43
40
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
.and(
|
|
41
|
+
def tag_conditions(tag)
|
|
42
|
+
condition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
|
|
43
|
+
.and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
|
|
47
44
|
.and(
|
|
48
|
-
|
|
45
|
+
tagging_arel_table[:tag_id].in(
|
|
49
46
|
tag_arel_table.project(tag_arel_table[:id]).where(tag_match_type(tag))
|
|
50
47
|
)
|
|
51
48
|
)
|
|
52
49
|
|
|
53
50
|
if options[:start_at].present?
|
|
54
|
-
|
|
51
|
+
condition = condition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
|
|
55
52
|
end
|
|
56
53
|
|
|
57
54
|
if options[:end_at].present?
|
|
58
|
-
|
|
55
|
+
condition = condition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
|
|
59
56
|
end
|
|
60
57
|
|
|
61
58
|
if options[:on].present?
|
|
62
|
-
|
|
59
|
+
condition = condition.and(tagging_arel_table[:context].eq(options[:on]))
|
|
63
60
|
end
|
|
64
61
|
|
|
65
62
|
if (owner = options[:owned_by]).present?
|
|
66
|
-
|
|
67
|
-
.and(
|
|
63
|
+
condition = condition.and(tagging_arel_table[:tagger_id].eq(owner.id))
|
|
64
|
+
.and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s))
|
|
68
65
|
end
|
|
69
66
|
|
|
70
|
-
|
|
67
|
+
condition
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# :match_all keeps its outer join. It counts a record's taggings and compares
|
|
71
|
+
# that to the number of tags matched, so it needs them joined -- and the
|
|
72
|
+
# GROUP BY it already carries collapses the duplicates a join would cause.
|
|
73
|
+
def match_all_join
|
|
74
|
+
return [] unless options[:match_all].present?
|
|
75
|
+
|
|
76
|
+
taggable_arel_table
|
|
77
|
+
.join(tagging_arel_table, Arel::Nodes::OuterJoin)
|
|
78
|
+
.on(match_all_on_conditions)
|
|
79
|
+
.join_sources
|
|
71
80
|
end
|
|
72
81
|
|
|
73
82
|
def match_all_on_conditions
|
|
@@ -107,15 +116,17 @@ module MakeTaggable::Taggable::TaggedWithQuery
|
|
|
107
116
|
|
|
108
117
|
def order_conditions
|
|
109
118
|
order_by = []
|
|
110
|
-
|
|
119
|
+
|
|
120
|
+
# The old expression here counted every tagging in the table, correlated
|
|
121
|
+
# to nothing, and asked for COUNT(taggings.*) while doing it -- invalid
|
|
122
|
+
# SQL that ordered nothing even where it parsed. The shared correlated
|
|
123
|
+
# count is what the :any strategy has always used.
|
|
124
|
+
if options[:order_by_matching_tag_count].present? && options[:match_all].blank?
|
|
125
|
+
order_by << matching_tag_count_order
|
|
126
|
+
end
|
|
111
127
|
|
|
112
128
|
order_by << options[:order] if options[:order].present?
|
|
113
129
|
order_by.join(", ")
|
|
114
130
|
end
|
|
115
|
-
|
|
116
|
-
def tagging_alias(tag)
|
|
117
|
-
alias_base_name = taggable_model.base_class.name.downcase
|
|
118
|
-
adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{MakeTaggable::Utils.sha_prefix(tag)}")
|
|
119
|
-
end
|
|
120
131
|
end
|
|
121
132
|
end
|
|
@@ -11,55 +11,26 @@ module MakeTaggable::Taggable::TaggedWithQuery
|
|
|
11
11
|
# @return [ActiveRecord::Relation]
|
|
12
12
|
#
|
|
13
13
|
def build
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
# No select of our own. This strategy filters with an EXISTS subquery and
|
|
15
|
+
# joins nothing, so Active Record's default select list is already right.
|
|
16
|
+
# Forcing `taggable_models.*` made COUNT() invalid and left a caller's own
|
|
17
|
+
# select appended after the star rather than replacing it.
|
|
18
|
+
taggable_model
|
|
19
|
+
.where(model_has_matching_taggings)
|
|
16
20
|
.order(Arel.sql(order_conditions))
|
|
17
21
|
.readonly(false)
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
private
|
|
21
25
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def model_has_at_least_one_tag
|
|
27
|
-
tagging_arel_table.project(Arel.star).where(at_least_one_tag).exists
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def at_least_one_tag
|
|
31
|
-
exists_contition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
|
|
32
|
-
.and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
|
|
33
|
-
.and(
|
|
34
|
-
tagging_arel_table[:tag_id].in(
|
|
35
|
-
tag_arel_table.project(tag_arel_table[:id]).where(tags_match_type)
|
|
36
|
-
)
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
if options[:start_at].present?
|
|
40
|
-
exists_contition = exists_contition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
if options[:end_at].present?
|
|
44
|
-
exists_contition = exists_contition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
if options[:on].present?
|
|
48
|
-
exists_contition = exists_contition.and(tagging_arel_table[:context].eq(options[:on]))
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
if (owner = options[:owned_by]).present?
|
|
52
|
-
exists_contition = exists_contition.and(tagging_arel_table[:tagger_id].eq(owner.id))
|
|
53
|
-
.and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s))
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
exists_contition
|
|
26
|
+
def model_has_matching_taggings
|
|
27
|
+
tagging_arel_table.project(Arel.star).where(matching_taggings).exists
|
|
57
28
|
end
|
|
58
29
|
|
|
59
30
|
def order_conditions
|
|
60
31
|
order_by = []
|
|
61
32
|
if options[:order_by_matching_tag_count].present?
|
|
62
|
-
order_by <<
|
|
33
|
+
order_by << matching_tag_count_order
|
|
63
34
|
end
|
|
64
35
|
|
|
65
36
|
order_by << options[:order] if options[:order].present?
|
|
@@ -24,20 +24,27 @@ module MakeTaggable::Taggable::TaggedWithQuery
|
|
|
24
24
|
.and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
|
|
25
25
|
.and(tags_match_type)
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
27
|
+
# Every option below narrows which taggings count as "carrying the tag".
|
|
28
|
+
# Left off, the subquery gathers taggings from other contexts and other
|
|
29
|
+
# times, and excludes records on the strength of them.
|
|
29
30
|
if options[:on].present?
|
|
30
31
|
on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
if options[:start_at].present?
|
|
35
|
+
on_condition = on_condition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if options[:end_at].present?
|
|
39
|
+
on_condition = on_condition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
|
|
40
|
+
end
|
|
41
|
+
|
|
33
42
|
taggable_arel_table[taggable_model.primary_key].not_in(
|
|
34
43
|
tagging_arel_table
|
|
35
44
|
.project(tagging_arel_table[:taggable_id])
|
|
36
45
|
.join(tag_arel_table)
|
|
37
46
|
.on(on_condition)
|
|
38
47
|
)
|
|
39
|
-
|
|
40
|
-
# FIXME: missing time scope, this is also missing in the original implementation
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
def owning_to_tagger
|
|
@@ -61,6 +61,46 @@ module MakeTaggable::Taggable::TaggedWithQuery
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# A condition selecting the taggings that tie a row of the taggable table to
|
|
65
|
+
# one of the tags being matched, narrowed by whichever of :on, :owned_by,
|
|
66
|
+
# :start_at and :end_at were given.
|
|
67
|
+
#
|
|
68
|
+
# It correlates to the taggable table, so it only means anything inside a
|
|
69
|
+
# subquery -- an EXISTS test, or a COUNT used for ordering.
|
|
70
|
+
def matching_taggings
|
|
71
|
+
condition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
|
|
72
|
+
.and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
|
|
73
|
+
.and(
|
|
74
|
+
tagging_arel_table[:tag_id].in(
|
|
75
|
+
tag_arel_table.project(tag_arel_table[:id]).where(tags_match_type)
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if options[:start_at].present?
|
|
80
|
+
condition = condition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if options[:end_at].present?
|
|
84
|
+
condition = condition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if options[:on].present?
|
|
88
|
+
condition = condition.and(tagging_arel_table[:context].eq(options[:on]))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if (owner = options[:owned_by]).present?
|
|
92
|
+
condition = condition.and(tagging_arel_table[:tagger_id].eq(owner.id))
|
|
93
|
+
.and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
condition
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Orders by how many of the matched taggings a row has, most first.
|
|
100
|
+
def matching_tag_count_order
|
|
101
|
+
"(SELECT count(*) FROM #{tagging_model.table_name} WHERE #{matching_taggings.to_sql}) desc"
|
|
102
|
+
end
|
|
103
|
+
|
|
64
104
|
def escaped_tag(tag)
|
|
65
105
|
tag = tag.downcase unless MakeTaggable.strict_case_match
|
|
66
106
|
MakeTaggable::Utils.escape_like(tag)
|