make_taggable 1.2.0 → 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 +59 -0
- data/docs/contexts.md +17 -0
- data/docs/querying.md +29 -6
- data/lib/make_taggable/taggable/core.rb +7 -4
- 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 +21 -8
- data/lib/make_taggable/taggable/tagged_with_query/query_base.rb +40 -0
- data/lib/make_taggable/taggable.rb +27 -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,65 @@ 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
|
+
|
|
40
|
+
## [1.2.1] - 2026-08-23
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `tagged_with(..., exclude: true)` raised on any model whose primary key is not `id`. The exclude
|
|
45
|
+
strategy built its `NOT IN` predicate against a hardcoded `id` column, where the other two
|
|
46
|
+
strategies already used the model's primary key.
|
|
47
|
+
|
|
48
|
+
- `tagged_with(..., on: <context>, exclude: true)` ignored the context entirely, gathering taggings
|
|
49
|
+
from every context, so a record tagged in one context was excluded from a query about another.
|
|
50
|
+
|
|
51
|
+
- `tagged_with([], exclude: true)` returned nothing rather than everything. Excluding no tags rules
|
|
52
|
+
nothing out, so the whole scope now stands. This restores the property that `tagged_with(list)`
|
|
53
|
+
and `tagged_with(list, exclude: true)` partition the scope between them for any list.
|
|
54
|
+
|
|
55
|
+
- A tag context whose name cannot become a Ruby method name -- one starting with a digit, say --
|
|
56
|
+
raised `SyntaxError` while the model was loading, from inside Active Record's association
|
|
57
|
+
builder. Since `SyntaxError` is not a `StandardError` it slipped past application rescues.
|
|
58
|
+
Contexts are now checked as they are declared and rejected with an `ArgumentError` naming the
|
|
59
|
+
context. Non-ASCII context names keep working.
|
|
60
|
+
|
|
61
|
+
### Internal
|
|
62
|
+
|
|
63
|
+
- The suite's schema teardown no longer depends on the order `connection.tables` returns. MySQL
|
|
64
|
+
ignores `DROP TABLE ... CASCADE` for foreign keys, so it only worked because `taggings` happens
|
|
65
|
+
to sort before `tags`.
|
|
66
|
+
|
|
8
67
|
## [1.2.0] - 2026-08-23
|
|
9
68
|
|
|
10
69
|
### Fixed
|
data/docs/contexts.md
CHANGED
|
@@ -40,6 +40,23 @@ user.skill_list_change # => [["jogging"], ["diving"]]
|
|
|
40
40
|
user.will_save_change_to_skill_list?
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
### Naming a context
|
|
44
|
+
|
|
45
|
+
Because the context becomes part of every name in the table above, it has to be usable as a Ruby
|
|
46
|
+
method and instance variable name. A context starting with a digit, or containing a hyphen or a
|
|
47
|
+
space, is rejected when the model declares it:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class Book < ActiveRecord::Base
|
|
51
|
+
make_taggable :"1categories"
|
|
52
|
+
end
|
|
53
|
+
# => ArgumentError: :"1categories" cannot be used as a tag context: make_taggable generates
|
|
54
|
+
# methods and instance variables from it, and "1categories_list" is not a valid Ruby name.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Validity is decided by asking Ruby, not by a pattern, so anything that makes a legal method name is
|
|
58
|
+
allowed — non-ASCII context names included.
|
|
59
|
+
|
|
43
60
|
## Adding contexts later
|
|
44
61
|
|
|
45
62
|
Calling `make_taggable` again adds contexts rather than replacing them, which is what lets a
|
data/docs/querying.md
CHANGED
|
@@ -28,11 +28,19 @@ empty relation rather than every record — worth knowing when the tags come fro
|
|
|
28
28
|
| `:exclude` | Match records carrying none of the tags |
|
|
29
29
|
| `:match_all` | Match records carrying only these tags and no others |
|
|
30
30
|
| `:wild` | Match tags *containing* the given text, i.e. `%sci%` |
|
|
31
|
-
| `:on` | Restrict to one context |
|
|
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
|
+
|
|
37
|
+
An empty tag list means "nothing matches" for the matching options and "nothing is ruled out" for
|
|
38
|
+
`:exclude`, so the two always partition the scope between them:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
Book.tagged_with([]) # => none
|
|
42
|
+
Book.tagged_with([], exclude: true) # => every book
|
|
43
|
+
```
|
|
36
44
|
|
|
37
45
|
`:wild` combines with `:any` or `:exclude`:
|
|
38
46
|
|
|
@@ -154,10 +162,25 @@ MakeTaggable::Tag.for_context(:skills) # used in this context, on any model
|
|
|
154
162
|
query time. If you set `MakeTaggable.tags_counter = false` that counter is not maintained and both
|
|
155
163
|
scopes will be wrong.
|
|
156
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
|
+
|
|
157
178
|
## Performance notes
|
|
158
179
|
|
|
159
|
-
- `tagged_with`
|
|
160
|
-
|
|
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.
|
|
161
184
|
- `:order_by_matching_tag_count` adds a correlated subquery to the `ORDER BY`. It is fine for a
|
|
162
185
|
page of results and expensive across a whole table.
|
|
163
186
|
- `all_tag_counts` joins the taggables to count them. Reach for `all_tags` when the counts are not
|
|
@@ -123,13 +123,14 @@ 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
|
|
131
131
|
# @option options [Time, Date] :end_at only tags applied before this time
|
|
132
|
-
# @return [ActiveRecord::Relation] empty when no tags are given
|
|
132
|
+
# @return [ActiveRecord::Relation] empty when no tags are given, except under `:exclude`,
|
|
133
|
+
# where excluding no tags leaves the whole scope standing
|
|
133
134
|
#
|
|
134
135
|
# @example Every tag
|
|
135
136
|
# User.tagged_with(["awesome", "cool"])
|
|
@@ -144,7 +145,9 @@ module MakeTaggable::Taggable
|
|
|
144
145
|
tag_list = MakeTaggable.default_parser.new(tags).parse
|
|
145
146
|
options = options.dup
|
|
146
147
|
|
|
147
|
-
|
|
148
|
+
# Asking for no tags matches nothing, but *excluding* no tags rules
|
|
149
|
+
# nothing out, so the whole scope stands.
|
|
150
|
+
return options[:exclude].present? ? all : none if tag_list.empty?
|
|
148
151
|
|
|
149
152
|
::MakeTaggable::Taggable::TaggedWithQuery.build(self, MakeTaggable::Tag, MakeTaggable::Tagging, tag_list, options)
|
|
150
153
|
end
|
|
@@ -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?
|
|
@@ -20,18 +20,31 @@ module MakeTaggable::Taggable::TaggedWithQuery
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
22
|
def tags_not_in_list
|
|
23
|
-
|
|
23
|
+
on_condition = tagging_arel_table[:tag_id].eq(tag_arel_table[:id])
|
|
24
|
+
.and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
|
|
25
|
+
.and(tags_match_type)
|
|
26
|
+
|
|
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.
|
|
30
|
+
if options[:on].present?
|
|
31
|
+
on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
|
|
32
|
+
end
|
|
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
|
+
|
|
42
|
+
taggable_arel_table[taggable_model.primary_key].not_in(
|
|
24
43
|
tagging_arel_table
|
|
25
44
|
.project(tagging_arel_table[:taggable_id])
|
|
26
45
|
.join(tag_arel_table)
|
|
27
|
-
.on(
|
|
28
|
-
tagging_arel_table[:tag_id].eq(tag_arel_table[:id])
|
|
29
|
-
.and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
|
|
30
|
-
.and(tags_match_type)
|
|
31
|
-
)
|
|
46
|
+
.on(on_condition)
|
|
32
47
|
)
|
|
33
|
-
|
|
34
|
-
# FIXME: missing time scope, this is also missing in the original implementation
|
|
35
48
|
end
|
|
36
49
|
|
|
37
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)
|
|
@@ -69,6 +69,7 @@ module MakeTaggable
|
|
|
69
69
|
#
|
|
70
70
|
def taggable_on(preserve_tag_order, *tag_types)
|
|
71
71
|
tag_types = tag_types.to_a.flatten.compact.map(&:to_sym)
|
|
72
|
+
tag_types.each { |tag_type| validate_tag_context!(tag_type) }
|
|
72
73
|
|
|
73
74
|
if taggable?
|
|
74
75
|
self.tag_types = (self.tag_types + tag_types).uniq
|
|
@@ -97,5 +98,31 @@ module MakeTaggable
|
|
|
97
98
|
include Ownership
|
|
98
99
|
include Related
|
|
99
100
|
end
|
|
101
|
+
|
|
102
|
+
# Rejects a context that cannot become the methods and instance variables the
|
|
103
|
+
# library generates from it.
|
|
104
|
+
#
|
|
105
|
+
# A context is interpolated straight into generated source -- `#{context}_list`,
|
|
106
|
+
# `#{context}_taggings`, `@#{context}_list`. A name that is not a valid identifier
|
|
107
|
+
# produces source Ruby cannot parse, and the resulting SyntaxError descends from
|
|
108
|
+
# ScriptError rather than StandardError, so it slips past an application's own
|
|
109
|
+
# rescue and takes the boot down pointing at Active Record's association builder
|
|
110
|
+
# rather than at the offending declaration.
|
|
111
|
+
#
|
|
112
|
+
# Validity is decided by asking Ruby rather than by pattern, so a context is
|
|
113
|
+
# accepted on exactly the terms the generated code needs -- non-ASCII names
|
|
114
|
+
# included, since those make perfectly good method names.
|
|
115
|
+
#
|
|
116
|
+
# @param tag_type [Symbol] the context being declared
|
|
117
|
+
# @return [void]
|
|
118
|
+
# @raise [ArgumentError] when the context cannot become an identifier
|
|
119
|
+
def validate_tag_context!(tag_type)
|
|
120
|
+
Object.new.instance_variable_defined?(:"@#{tag_type}_list")
|
|
121
|
+
rescue NameError
|
|
122
|
+
raise ArgumentError,
|
|
123
|
+
"#{tag_type.inspect} cannot be used as a tag context: " \
|
|
124
|
+
"make_taggable generates methods and instance variables from it, and " \
|
|
125
|
+
"\"#{tag_type}_list\" is not a valid Ruby name."
|
|
126
|
+
end
|
|
100
127
|
end
|
|
101
128
|
end
|