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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7988c41c462a75321cf31cb13888a01d87a4dec964b13d2bb8c94876d7291d2a
4
- data.tar.gz: 76c39347c15e44dd9bad00317aa4e27f4ef58aadd638507340a74fe6bd5bbbd3
3
+ metadata.gz: 7b11d21dbd46411b5b827d1b70cb44bcb641259804a32fa221597e56e02aa906
4
+ data.tar.gz: 3ebf2e595313e19cc88e28ab7541c40bae5fd1c702a09b8f60873fb68605bfcd
5
5
  SHA512:
6
- metadata.gz: 89496e3b067d1c4707580b30b4ad1a1be0701e8aa03f1e7de30d2c229b4eec96afd82eec4675e89c32cf5e8f0b3ccf28d7afb0ffcf4a7ada5b9ef2169232dcc2
7
- data.tar.gz: bbeef557e819249bc1d13668ec6437401dfcfbe3d995874535c86d5b86747ff46d5e0bb3383905343057c2f773c0658766eed9580f6173789e288571a516f0bb
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` | 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
+
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` with several tags and no `:any` adds one join per tag. Matching a dozen tags in a
160
- 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.
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 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
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
- return none if tag_list.empty?
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(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?
@@ -20,18 +20,31 @@ module MakeTaggable::Taggable::TaggedWithQuery
20
20
  private
21
21
 
22
22
  def tags_not_in_list
23
- taggable_arel_table[:id].not_in(
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
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.2.0"
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.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy