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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99cb64f83f0cef74cc21f3fc09a341e62db5a2248b8c88ef5d720de4b9446abb
4
- data.tar.gz: c3c08be5a20df6100567523555be2394dc32665770f6c65226e0fe91b3be5652
3
+ metadata.gz: 7b11d21dbd46411b5b827d1b70cb44bcb641259804a32fa221597e56e02aa906
4
+ data.tar.gz: 3ebf2e595313e19cc88e28ab7541c40bae5fd1c702a09b8f60873fb68605bfcd
5
5
  SHA512:
6
- metadata.gz: ad8701479ff9565786acebff64af05fe5aaa88eddbcfc660f88778e4ca885a0d26438677754cfa3072dd54e3eef6374ebed4f1e4de6a70fe911824050cb0b19e
7
- data.tar.gz: 47804d66800ff4df491f3b7037ba5e82c131fbaf4c9eb8442a650ed6ecbf1772483d2ae2237c5437cb8ffefca831d107744a3ffacb2c517cb65a161063bc6d0e
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` | With `:any`, order by how many tags matched, most first |
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` with several tags and no `:any` adds one join per tag. Matching a dozen tags in a
168
- single call generates a dozen joins; prefer `any: true` where the semantics allow it.
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 with `:any`, order by
127
- # how many tags matched, most first
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(each_tag_in_list)
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
- def each_tag_in_list
24
- arel_join = taggable_arel_table
25
-
26
- tag_list.each do |tag|
27
- tagging_alias = tagging_arel_table.alias(tagging_alias(tag))
28
- arel_join = arel_join
29
- .join(tagging_alias)
30
- .on(on_conditions(tag, tagging_alias))
31
- end
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
- arel_join.join_sources
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 on_conditions(tag, tagging_alias)
45
- on_condition = tagging_alias[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
46
- .and(tagging_alias[:taggable_type].eq(taggable_model.base_class.name))
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
- tagging_alias[:tag_id].in(
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
- on_condition = on_condition.and(tagging_alias[:created_at].gteq(options[:start_at]))
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
- on_condition = on_condition.and(tagging_alias[:created_at].lteq(options[:end_at]))
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
- on_condition = on_condition.and(tagging_alias[:context].eq(options[:on]))
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
- on_condition = on_condition.and(tagging_alias[:tagger_id].eq(owner.id))
67
- .and(tagging_alias[:tagger_type].eq(owner.class.base_class.to_s))
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
- on_condition
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
- order_by << tagging_arel_table.project(tagging_arel_table[Arel.star].count.as("taggings_count")).order("taggings_count DESC").to_sql if options[:order_by_matching_tag_count].present? && options[:match_all].blank?
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
- taggable_model.select(all_fields)
15
- .where(model_has_at_least_one_tag)
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 all_fields
23
- taggable_arel_table[Arel.star]
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 << "(SELECT count(*) FROM #{tagging_model.table_name} WHERE #{at_least_one_tag.to_sql}) desc"
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
- # Without this the subquery gathers taggings from every context, so a
28
- # record tagged in one context is excluded from a query about another.
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)
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.2.1"
9
+ VERSION = "1.3.0"
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: make_taggable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy