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 +4 -4
- data/CHANGELOG.md +18 -0
- data/docs/querying.md +29 -0
- data/lib/make_taggable/taggable/collection.rb +33 -3
- data/lib/make_taggable/taggable/core.rb +43 -1
- 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: 990809ed5f39d8db0f974dbc5666dd040d5d8297423a3fc13b3af555ae5f1735
|
|
4
|
+
data.tar.gz: 20a3ec9542d5089d7fa953540e6233ad22a0f8857ac21dcac664b9833cc2bdb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|