make_taggable 1.5.0 → 1.6.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: 1d900a8ac37e0f88ead6c0516fd30ddee5e8cc11c893b7d64e85c090a8b59598
4
- data.tar.gz: b364eef565e3a171c56558f928360de5230c445b4b210e1aab8f536f9b998c70
3
+ metadata.gz: 990809ed5f39d8db0f974dbc5666dd040d5d8297423a3fc13b3af555ae5f1735
4
+ data.tar.gz: 20a3ec9542d5089d7fa953540e6233ad22a0f8857ac21dcac664b9833cc2bdb1
5
5
  SHA512:
6
- metadata.gz: 611fd4635e580ff5aaaee314600d2d66e7cdb70a2bd22d8988e3eb541c3d632fc25625e485880821beeec8f0bfb71677242f3d0d14ba96e98e0dd4d15038e0ae
7
- data.tar.gz: b1713ea6a65eb7a8e45dfb0d4b8d41c9d72334cfb8de93ef2c1e60a21a6a4e04758acdcb7761a117b8b275f0aecb3e98d78258a8c7f8389f433836353e676958
6
+ metadata.gz: 44636fc042b034366c843c5ca909ea2ddbafbbd6d75d69976416b0fa956baffbb81e5beab0d746f87e1b5db320b3a42dc34b87fb632dcff3545d1e5f2461b502
7
+ data.tar.gz: 113d18d8f21957b8ab11d3c044144afccc21c5c11726c3f757587fbf73d252cdb14913b0dd99168b7b8e725346e85f1afbcfc4392ce32605373732d0c7402a59
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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.6.0] - 2026-08-23
9
+
10
+ ### Fixed
11
+
12
+ - `includes(:tags)` made reading tag lists **slower**: it cost two extra queries and saved none,
13
+ because `tag_list` always queried for a context's unowned tags rather than reading what had been
14
+ preloaded. Reading five records' lists went from 8 queries to 3, and the count no longer grows
15
+ with the collection. Records loaded without the preload are unaffected.
16
+
17
+ - `tag_counts_on` raised `sub-select returns N columns - expected 1` on a relation built with
18
+ `includes`. An eager load builds its own column list, which survived the `except(:select)` used to
19
+ reduce the scope to primary keys.
20
+
21
+ - `all_tags` and `all_tag_counts` could not be ordered by a `taggings` column --
22
+ `order: "taggings.created_at desc"` raised `no such column`. The derived table they join now
23
+ exposes the latest `created_at` for each tag, so ordering by it means "when this tag was last
24
+ applied".
25
+
8
26
  ## [1.5.0] - 2026-08-23
9
27
 
10
28
  ### Added
data/docs/querying.md CHANGED
@@ -168,6 +168,18 @@ MakeTaggable::Tag.for_context(:skills) # used in this context, on any model
168
168
  query time. If you set `MakeTaggable.tags_counter = false` that counter is not maintained and both
169
169
  scopes will be wrong.
170
170
 
171
+ ## Reading tag lists across a collection
172
+
173
+ Reading `<context>_list` on each of a set of records queries once per record. Eager load the tags to
174
+ avoid it:
175
+
176
+ ```ruby
177
+ Book.includes(:tags).each { |book| book.tag_list } # constant queries, whatever the count
178
+ ```
179
+
180
+ The lists are then read from the preload. Owned tags are still excluded from `tag_list`, as they are
181
+ without it.
182
+
171
183
  ## Grouping and ordering by tagging columns
172
184
 
173
185
  `tagged_with` does not join the taggings table, so a `taggings` column is not in scope on the
@@ -181,6 +193,23 @@ Book.tagged_with("sci-fi").joins(:taggings).order("taggings.created_at desc")
181
193
  Note that joining reintroduces one row per tagging, which is exactly what `tagged_with` avoids on
182
194
  its own — add `.distinct` if you want records back rather than matches.
183
195
 
196
+ ## Counting tags for a subset of records
197
+
198
+ `tag_counts_on` and `all_tags` apply to whatever scope they are called on, so narrowing the records
199
+ narrows the counts:
200
+
201
+ ```ruby
202
+ Book.where(published: true).tag_counts_on(:genres)
203
+ Book.joins(:author).where(authors: {country: "IE"}).tag_counts_on(:genres)
204
+ ```
205
+
206
+ `:order` accepts a `taggings` column, which orders by when a tag was last applied:
207
+
208
+ ```ruby
209
+ Book.all_tags(order: "taggings.created_at desc") # most recently used first
210
+ Book.all_tag_counts(order: "count desc", limit: 10)
211
+ ```
212
+
184
213
  ## Performance notes
185
214
 
186
215
  - `tagged_with` tests for each tag with an `EXISTS` subquery, one per tag, and joins nothing. That
@@ -107,7 +107,9 @@ module MakeTaggable::Taggable
107
107
  options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
108
108
 
109
109
  ## Generate scope:
110
- tagging_scope = MakeTaggable::Tagging.select("#{MakeTaggable::Tagging.table_name}.tag_id")
110
+ tagging_scope = MakeTaggable::Tagging.select(
111
+ "#{MakeTaggable::Tagging.table_name}.tag_id, #{last_applied_at_projection}"
112
+ )
111
113
  tag_scope = MakeTaggable::Tag.select("#{MakeTaggable::Tag.table_name}.*").order(options[:order]).limit(options[:limit])
112
114
 
113
115
  # Joins and conditions
@@ -147,7 +149,9 @@ module MakeTaggable::Taggable
147
149
  options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
148
150
 
149
151
  ## Generate scope:
150
- tagging_scope = MakeTaggable::Tagging.select("#{MakeTaggable::Tagging.table_name}.tag_id, COUNT(#{MakeTaggable::Tagging.table_name}.tag_id) AS tags_count")
152
+ tagging_scope = MakeTaggable::Tagging.select(
153
+ "#{MakeTaggable::Tagging.table_name}.tag_id, COUNT(#{MakeTaggable::Tagging.table_name}.tag_id) AS tags_count, #{last_applied_at_projection}"
154
+ )
151
155
  tag_scope = MakeTaggable::Tag.select("#{MakeTaggable::Tag.table_name}.*, #{MakeTaggable::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
152
156
 
153
157
  # Current model is STI descendant, so add type checking to the join condition
@@ -200,12 +204,38 @@ module MakeTaggable::Taggable
200
204
  scoped_ids = pluck(table_name_pkey)
201
205
  tagging_scope = tagging_scope.where("#{MakeTaggable::Tagging.table_name}.taggable_id IN (?)", scoped_ids)
202
206
  else
203
- tagging_scope = tagging_scope.where("#{MakeTaggable::Tagging.table_name}.taggable_id IN(#{safe_to_sql(except(:select).select(table_name_pkey))})")
207
+ tagging_scope = tagging_scope.where("#{MakeTaggable::Tagging.table_name}.taggable_id IN(#{safe_to_sql(taggable_ids_scope(table_name_pkey))})")
204
208
  end
205
209
 
206
210
  tagging_scope
207
211
  end
208
212
 
213
+ # The current scope reduced to primary keys, for embedding in an IN clause.
214
+ #
215
+ # `except(:select)` is not enough on its own. An eager load builds its own column list rather
216
+ # than storing it in select_values, so it survives and the subquery comes back with a column
217
+ # per attribute of every table involved -- where the IN needs exactly one. Turning the eager
218
+ # load into a join keeps any condition on the joined table while selecting only the key.
219
+ def taggable_ids_scope(table_name_pkey)
220
+ scope = except(:select)
221
+ eager_loaded = scope.includes_values + scope.eager_load_values
222
+
223
+ scope = scope.except(:includes, :eager_load, :preload).left_joins(*eager_loaded) if eager_loaded.any?
224
+
225
+ scope.select(table_name_pkey)
226
+ end
227
+
228
+ # The tagging scope is embedded as a derived table aliased to the taggings table name, so
229
+ # `order: "taggings.created_at"` resolves against that rather than the real table -- which
230
+ # projected tag_id alone, and the column appeared not to exist.
231
+ #
232
+ # It groups by tag_id, so a tag may stand for many taggings and there is no single created_at
233
+ # to expose. The latest is the useful one: ordering by it means "when this tag was last
234
+ # applied".
235
+ def last_applied_at_projection
236
+ "MAX(#{MakeTaggable::Tagging.table_name}.created_at) AS created_at"
237
+ end
238
+
209
239
  def tagging_conditions(options)
210
240
  tagging_conditions = []
211
241
  tagging_conditions.push sanitize_sql(["#{MakeTaggable::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
@@ -408,7 +408,7 @@ module MakeTaggable::Taggable
408
408
  if cached_tag_list_on(context) && ensure_included_cache_methods! && self.class.caching_tag_list_on?(context)
409
409
  MakeTaggable.default_parser.new(cached_tag_list_on(context)).parse
410
410
  else
411
- MakeTaggable::TagList.new(tags_on(context).map(&:name))
411
+ MakeTaggable::TagList.new(unowned_tag_names_on(context))
412
412
  end
413
413
 
414
414
  # Note what was there the first time the list is built, before anything
@@ -469,6 +469,48 @@ module MakeTaggable::Taggable
469
469
  end.to_a
470
470
  end
471
471
 
472
+ ##
473
+ # The names of a context's unowned tags, read from the preloaded taggings where they are
474
+ # available and queried where they are not.
475
+ #
476
+ # `includes(:tags)` loads the per-context taggings association as well as the tags themselves,
477
+ # and those taggings carry `tagger_id` -- which is what makes them usable here, since the list
478
+ # excludes owned tags and the tags association alone cannot say which are owned.
479
+ #
480
+ # @param context [Symbol, String] the tagging context
481
+ # @return [Array<String>]
482
+ #
483
+ # @api private
484
+ #
485
+ def unowned_tag_names_on(context)
486
+ preloaded = preloaded_taggings_on(context)
487
+ return tags_on(context).map(&:name) unless preloaded
488
+
489
+ preloaded.map { |tagging| tagging.tag.name }
490
+ end
491
+
492
+ ##
493
+ # A context's taggings if they are already in memory, otherwise `nil`.
494
+ #
495
+ # Only unowned taggings are returned, in tagging order where the model preserves it, so the
496
+ # result matches what {#tags_on} would have queried.
497
+ #
498
+ # @param context [Symbol, String] the tagging context
499
+ # @return [Array<MakeTaggable::Tagging>, NilClass]
500
+ #
501
+ # @api private
502
+ #
503
+ def preloaded_taggings_on(context)
504
+ name = :"#{context.to_s.singularize}_taggings"
505
+ return unless self.class.reflect_on_association(name)
506
+
507
+ association = association(name)
508
+ return unless association.loaded?
509
+
510
+ taggings = association.target.reject(&:tagger_id)
511
+ self.class.preserve_tag_order? ? taggings.sort_by(&:id) : taggings
512
+ end
513
+
472
514
  ##
473
515
  # Returns all tags that are not owned of a given context
474
516
  def tags_on(context)
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.5.0"
9
+ VERSION = "1.6.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.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy