praxis 2.0.pre.13 → 2.0.pre.17

Sign up to get free protection for your applications and to get access to all the features.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/bin/praxis +6 -0
  4. data/lib/praxis/api_definition.rb +8 -4
  5. data/lib/praxis/collection.rb +11 -0
  6. data/lib/praxis/docs/open_api/response_object.rb +21 -6
  7. data/lib/praxis/extensions/attribute_filtering.rb +14 -1
  8. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +81 -23
  9. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +1 -1
  10. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +3 -4
  11. data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +52 -12
  12. data/lib/praxis/mapper/resource.rb +2 -2
  13. data/lib/praxis/media_type_identifier.rb +11 -1
  14. data/lib/praxis/response_definition.rb +46 -66
  15. data/lib/praxis/responses/http.rb +3 -1
  16. data/lib/praxis/tasks/routes.rb +6 -6
  17. data/lib/praxis/version.rb +1 -1
  18. data/spec/praxis/action_definition_spec.rb +3 -1
  19. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +110 -35
  20. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +25 -3
  21. data/spec/praxis/extensions/attribute_filtering/filters_parser_spec.rb +13 -5
  22. data/spec/praxis/extensions/support/spec_resources_active_model.rb +2 -0
  23. data/spec/praxis/mapper/resource_spec.rb +3 -3
  24. data/spec/praxis/media_type_identifier_spec.rb +15 -1
  25. data/spec/praxis/response_definition_spec.rb +37 -129
  26. data/spec/spec_helper.rb +1 -0
  27. data/tasks/thor/templates/generator/example_app/app/v1/concerns/href.rb +33 -0
  28. data/tasks/thor/templates/generator/example_app/app/v1/resources/base.rb +4 -0
  29. data/tasks/thor/templates/generator/example_app/config/environment.rb +1 -1
  30. data/tasks/thor/templates/generator/scaffold/implementation/resources/item.rb +2 -2
  31. metadata +7 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4be08481cbad0604fdb8e45dfc66aec895478a0b7578544b773af7fa6f3863f
4
- data.tar.gz: 122625c252f5d9a2166e8958408bedf7ac5aee13500488e44c453ddeff3fbcc8
3
+ metadata.gz: dafcffa2e0d146f5b601ed796cb482c5068482757fc2f88770783536da52ad7e
4
+ data.tar.gz: c6f8875641b0e418128dd8f8a923a3df639b1a409e65976472c24b6fac52f90e
5
5
  SHA512:
6
- metadata.gz: 6472e4ef1f9ad601ed5c13774a336b93d52356a3a0bccb3a3707953658918a79457d314ceca1ce676a15f51c10bcebb4556388c95ba3aeca2cbec40625b13734
7
- data.tar.gz: d8a4bad9469579530974d3c61d61fd7441b4e9d48d2ecb90ac81d4951cda0293428f750011e406e755103abb162b4fb1c60b87a584fe77da74c68dafaa997ecd
6
+ metadata.gz: 4728bf36f52fd0bf1f73c9efe02190cde40308be2378232897570deab1aa9e5fcbd1ca2b4335b9ad168c4eedda18143a512512e6c132d851b6f6c9447e891d19
7
+ data.tar.gz: 1d689d714604b4841f4059b1ef9734f1ceae0a9afcc7e7d75286be28f67cfe2a51cb86fafdd6c66b2ac40acc72bd431c12ad1b33aa91990ceecd690abc9e01ad
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## next
4
4
 
5
+ ## 2.0.pre.17
6
+ * Changed the Parameter Filtering to use left outer joins (and extra conditions), to allow for the proper results when OR clauses are involved in certain configurations.
7
+ * Built support for allowing filtering directly on associations using `!` and `!!` operators. This allows to filter results where
8
+ there are no associated rows (`!!`) or if there are some associated rows (`!`)
9
+ * Allow implicit definition of `filters_mapping` for filter names that match top-level associations of the model (i.e., like we do for the columns)
10
+ ## 2.0.pre.16
11
+
12
+ * Updated `Resource.property` signature to only accept known named arguments (`dependencies` and `though` at this time) to spare anyone else from going insane wondering why their `depednencies` aren't working.
13
+ * Fixed issue with Filtering Params, that occurred with using the ! or !! operators on String-typed fields.
14
+
15
+ ## 2.0.pre.14
16
+
17
+ * More encoding/decoding robustness for filters.
18
+ * Specs for how to encode filters are now properly defined by:
19
+ * The "value" of the filters query string needs to be URI encoded (like any other query string value). This encoding is subject to the normal rules, and therefore "could" leave some of the URI unreserved characters (i.e., 'markers') unencoded depending on the client (Section 2.2 of https://tools.ietf.org/html/rfc2396).
20
+ * The "values" for any of the conditions in the contents of the filters, however, will need to be properly "escaped" as well (prior to URL-encoding the whole syntax string itself like described above). This means that any match value needs to ensure that it has (at least) "(",")","|","&" and "," escaped as they are reserved characters for the filter expression syntax. For example, if I want to search for a name with value "Rocket&(Pants)", I need to first compose the syntax by: "name=<escaped Rocket&(Pants)>, which is "name=Rocket%26%28Pants%29" and then, just URI encode that query string value for the filters parameter in the URL like any other. For example: "filters=name%3DRocket%2526%2528Pants%2529"
21
+ * When using a multi-match (csv-separated) list of values, you need to escape each of the values as well, leaving the 'comma' unescape, as that's part of the syntax. Then uri-encode it all for the filters query string parameter value like above.
22
+ * Now, one can properly differentiate between fuzzy query prefix/postfix, and the literal data to search for (which can be or include '*'). Report that multi-matches (i.e., csv separated values for a single field, which translate into "IN" clauses) is not allowed if fuzzy matches are received (need to use multiple OR clauses for it).
23
+
5
24
  ## 2.0.pre.13
6
25
 
7
26
  * Fix filters parser regression, which would incorrectly decode url-encoded values
data/bin/praxis CHANGED
@@ -9,6 +9,12 @@ rescue Bundler::GemfileNotFound
9
9
  # no-op: we might be installed as a system gem
10
10
  end
11
11
 
12
+ if ARGV[0] == "version"
13
+ require 'praxis/version'
14
+ puts "Praxis version #{Praxis::VERSION}"
15
+ exit 0
16
+ end
17
+
12
18
  if ["routes","docs","console"].include? ARGV[0]
13
19
  require 'rake'
14
20
  require 'praxis'
@@ -97,8 +97,10 @@ module Praxis
97
97
  description( description || 'Standard response for successful HTTP requests.' )
98
98
 
99
99
  media_type media_type
100
- location location
101
- headers headers if headers
100
+ location if location
101
+ headers&.each do |(name, value)|
102
+ header(name: name, value: value)
103
+ end
102
104
  end
103
105
 
104
106
  api.response_template :created do |media_type: nil, location: nil, headers: nil, description: nil|
@@ -106,8 +108,10 @@ module Praxis
106
108
  description( description || 'The request has been fulfilled and resulted in a new resource being created.' )
107
109
 
108
110
  media_type media_type if media_type
109
- location location
110
- headers headers if headers
111
+ location if location
112
+ headers&.each do |(name, value)|
113
+ header(name: name, value: value)
114
+ end
111
115
  end
112
116
  end
113
117
 
@@ -32,5 +32,16 @@ module Praxis
32
32
  @member_type.domain_model
33
33
  end
34
34
 
35
+ def self.json_schema_type
36
+ :array
37
+ end
38
+
39
+ def self.as_json_schema(**args)
40
+ the_type = @attribute && @attribute.type || member_type
41
+ {
42
+ type: json_schema_type,
43
+ items: { '$ref': "#/components/schemas/#{the_type.id}" }
44
+ }
45
+ end
35
46
  end
36
47
  end
@@ -16,12 +16,27 @@ module Praxis
16
16
 
17
17
  def dump_response_headers_object( headers )
18
18
  headers.each_with_object({}) do |(name,data),accum|
19
- # data is a hash with :value and :type keys
20
- # How did we say in that must match a value in json schema again??
21
- accum[name] = {
22
- schema: SchemaObject.new(info: data[:type])
23
- # allowed values: [ data[:value] ] ??? is this the right json schema way?
24
- }
19
+ # each header comes from Praxis::ResponseDefinition
20
+ # the keys are the header names, and value can be:
21
+ # "true" => means it only needs to exist
22
+ # String => which means that it has to fully match
23
+ # Regex => which means it has to regexp match it
24
+
25
+ # Get the schema from the type (defaulting to string in case the type doesn't have the as_json_schema defined)
26
+ schema = data[:attribute].type.as_json_schema rescue { type: :string }
27
+ hash = { description: data[:description] || '', schema: schema }
28
+ # Note, our Headers in response definition are not full types...they're basically only
29
+ # strings, which can either match anything, match the exact word or match a regex
30
+ # they don't even have a description...
31
+ data_value = data[:value]
32
+ if data_value.is_a? String
33
+ hash[:pattern] = "^#{data_value}$" # Exact String match
34
+ elsif data_value.is_a? Regexp
35
+ sanitized_pattern = data_value.inspect[1..-2] #inspect returns enclosing '/' characters
36
+ hash[:pattern] = sanitized_pattern
37
+ end
38
+
39
+ accum[name] = hash
25
40
  end
26
41
  end
27
42
 
@@ -1,2 +1,15 @@
1
1
  require 'praxis/extensions/attribute_filtering/filtering_params'
2
- require 'praxis/extensions/attribute_filtering/filter_tree_node'
2
+ require 'praxis/extensions/attribute_filtering/filter_tree_node'
3
+ module Praxis
4
+ module Extensions
5
+ module AttributeFiltering
6
+ class MultiMatchWithFuzzyNotAllowedByAdapter < StandardError
7
+ def initialize
8
+ msg = 'Matching multiple, comma-separated values with fuzzy matches for a single field is not allowed by this DB adapter'\
9
+ 'Please use multiple OR clauses instead.'
10
+ super(msg)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -23,7 +23,8 @@ module Praxis
23
23
  end
24
24
 
25
25
  class ActiveRecordFilterQueryBuilder
26
- attr_reader :model, :filters_map
26
+ REFERENCES_STRING_SEPARATOR = '/'
27
+ attr_reader :model, :filters_map
27
28
 
28
29
  # Base query to build upon
29
30
  def initialize(query: , model:, filters_map:, debug: false)
@@ -48,9 +49,8 @@ module Praxis
48
49
  end
49
50
 
50
51
  def craft_filter_query(nodetree, for_model:)
51
- result = _compute_joins_and_conditions_data(nodetree, model: for_model)
52
+ result = _compute_joins_and_conditions_data(nodetree, model: for_model, parent_reflection: nil)
52
53
  return @initial_query if result[:conditions].empty?
53
-
54
54
 
55
55
  # Find the root group (usually an AND group) but can be an OR group, or nil if there's only 1 condition
56
56
  root_parent_group = result[:conditions].first[:node_object].parent_group || result[:conditions].first[:node_object]
@@ -59,20 +59,35 @@ module Praxis
59
59
  end
60
60
 
61
61
  # Process the joins
62
- query_with_joins = result[:associations_hash].empty? ? @initial_query : @initial_query.joins(result[:associations_hash])
62
+ query_with_joins = result[:associations_hash].empty? ? @initial_query : @initial_query.left_outer_joins(result[:associations_hash])
63
63
 
64
64
  # Proc to apply a single condition
65
65
  apply_single_condition = Proc.new do |condition, associated_query|
66
66
  colo = condition[:model].columns_hash[condition[:name].to_s]
67
67
  column_prefix = condition[:column_prefix]
68
- #Mark where clause referencing the appropriate alias
69
- associated_query = associated_query.references(build_reference_value(column_prefix, query: associated_query))
68
+ association_key_column = \
69
+ if ref = condition[:parent_reflection]
70
+ # get the target model of the association(where the assoc pk is)
71
+ target_model = condition[:parent_reflection].klass
72
+ target_model.columns_hash[condition[:parent_reflection].association_primary_key]
73
+ else
74
+ nil
75
+ end
76
+
77
+ # Mark where clause referencing the appropriate alias IF it's not the root table, as there is no association to reference
78
+ # If we added root table as a reference, we better make sure it is not quoted, as it actually makes AR to see it as an
79
+ # unmatched reference and eager loads the whole association (it means eager load ALL the things). Not good.
80
+ unless for_model.table_name == column_prefix
81
+ associated_query = associated_query.references(build_reference_value(column_prefix, query: associated_query))
82
+ end
70
83
  self.class.add_clause(
71
84
  query: associated_query,
72
85
  column_prefix: column_prefix,
73
86
  column_object: colo,
74
87
  op: condition[:op],
75
- value: condition[:value]
88
+ value: condition[:value],
89
+ fuzzy: condition[:fuzzy],
90
+ association_key_column: association_key_column,
76
91
  )
77
92
  end
78
93
 
@@ -137,7 +152,8 @@ module Praxis
137
152
  def _mapped_filter(name)
138
153
  target = @filters_map[name]
139
154
  unless target
140
- if @model.attribute_names.include?(name.to_s)
155
+ filter_name = name.to_s
156
+ if (@model.attribute_names + @model.reflections.keys).include?(filter_name)
141
157
  # Cache it in the filters mapping (to avoid later lookups), and return it.
142
158
  @filters_map[name] = name
143
159
  target = name
@@ -175,32 +191,64 @@ module Praxis
175
191
  end
176
192
 
177
193
  # Calculate join tree and conditions array for the nodetree object and its children
178
- def _compute_joins_and_conditions_data(nodetree, model:)
194
+ def _compute_joins_and_conditions_data(nodetree, model:, parent_reflection:)
179
195
  h = {}
180
196
  conditions = []
181
197
  nodetree.children.each do |name, child|
182
- child_model = model.reflections[name.to_s].klass
183
- result = _compute_joins_and_conditions_data(child, model: child_model)
184
- h[name] = result[:associations_hash]
198
+ child_reflection = model.reflections[name.to_s]
199
+ result = _compute_joins_and_conditions_data(child, model: child_reflection.klass, parent_reflection: child_reflection)
200
+ h[name] = result[:associations_hash]
201
+
185
202
  conditions += result[:conditions]
186
203
  end
187
- column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? model.table_name : nodetree.path.join('/')
188
- #column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? nil : nodetree.path.join('/')
204
+
205
+ column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? model.table_name : nodetree.path.join(REFERENCES_STRING_SEPARATOR)
189
206
  nodetree.conditions.each do |condition|
190
- conditions += [condition.merge(column_prefix: column_prefix, model: model)]
207
+ # If it's a final ! or !! operation on an association from the parent, it means we need to add a condition
208
+ # on the existence (or lack of) of the whole associated table
209
+ ref = model.reflections[condition[:name].to_s]
210
+ if ref && ['!','!!'].include?(condition[:op])
211
+ cp = (nodetree.path + [condition[:name].to_s]).join(REFERENCES_STRING_SEPARATOR)
212
+ conditions += [condition.merge(column_prefix: cp, model: model, parent_reflection: ref)]
213
+ h[condition[:name]] = {}
214
+ else
215
+ # Save the parent reflection where the condition applies as well (used later to get assoc keys)
216
+ conditions += [condition.merge(column_prefix: column_prefix, model: model, parent_reflection: parent_reflection)]
217
+ end
218
+
191
219
  end
192
220
  {associations_hash: h, conditions: conditions}
193
221
  end
194
222
 
195
- def self.add_clause(query:, column_prefix:, column_object:, op:, value:)
196
- likeval = get_like_value(value)
223
+ def self.add_clause(query:, column_prefix:, column_object:, op:, value:,fuzzy:, association_key_column:)
224
+ likeval = get_like_value(value,fuzzy)
225
+
226
+ association_op = nil
197
227
  case op
198
228
  when '!' # name! means => name IS NOT NULL (and the incoming value is nil)
199
229
  op = '!='
200
230
  value = nil # Enforce it is indeed nil (should be)
231
+ association_op = :not_null if association_key_column && !column_object
201
232
  when '!!'
202
233
  op = '='
203
234
  value = nil # Enforce it is indeed nil (should be)
235
+ association_op = :null if association_key_column && !column_object
236
+ end
237
+
238
+ if association_op
239
+ neg = association_op == :not_null ? true : false
240
+ qr = quote_right_part(query: query, value: nil, column_object: association_key_column, negative: neg)
241
+ return query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: association_key_column)} #{qr}")
242
+ end
243
+
244
+ # Add an AND along with the condition, which ensures the left outter join 'exists' for it
245
+ # Normally this wouldn't be necessary as a condition on a given value mathing would imply the related row was there
246
+ # but this is not the case for NULL conditions, as the foreign column would match a NULL value, but not because the related column
247
+ # is NULL, but because the whole missing related row would appear with all fields null
248
+ # NOTE: we don't need to do it for conditions applying to the root of the tree (there isn't a join to it)
249
+ if association_key_column
250
+ qr = quote_right_part(query: query, value: nil, column_object: association_key_column, negative: true)
251
+ query = query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: association_key_column)} #{qr}")
204
252
  end
205
253
 
206
254
  case op
@@ -265,12 +313,22 @@ module Praxis
265
313
  end
266
314
 
267
315
  # Returns nil if the value was not a fuzzzy pattern
268
- def self.get_like_value(value)
269
- if value.is_a?(String) && (value[-1] == '*' || value[0] == '*')
270
- likeval = value.dup
271
- likeval[-1] = '%' if value[-1] == '*'
272
- likeval[0] = '%' if value[0] == '*'
273
- likeval
316
+ def self.get_like_value(value,fuzzy)
317
+ is_fuzzy = fuzzy.is_a?(Array) ? !fuzzy.compact.empty? : fuzzy
318
+ if is_fuzzy
319
+ unless value.is_a?(String)
320
+ raise MultiMatchWithFuzzyNotAllowedByAdapter.new
321
+ end
322
+ case fuzzy
323
+ when :start_end
324
+ '%'+value+'%'
325
+ when :start
326
+ '%'+value
327
+ when :end
328
+ value+'%'
329
+ end
330
+ else
331
+ nil
274
332
  end
275
333
  end
276
334
 
@@ -15,7 +15,7 @@ module Praxis
15
15
  if components.empty?
16
16
  return
17
17
  elsif components.size == 1
18
- @conditions << hash.slice(:name, :op, :value, :node_object)
18
+ @conditions << hash.slice(:name, :op, :value, :fuzzy, :node_object)
19
19
  else
20
20
  children_data[components.first] ||= []
21
21
  children_data[components.first] << hash
@@ -182,7 +182,7 @@ module Praxis
182
182
  else
183
183
  spec[:values]
184
184
  end
185
- accum.push(name: attr_name, op: spec[:op], value: coerced , node_object: spec[:node_object])
185
+ accum.push(name: attr_name, op: spec[:op], value: coerced , fuzzy: spec[:fuzzies], node_object: spec[:node_object])
186
186
  end
187
187
  new(accum)
188
188
  end
@@ -222,10 +222,9 @@ module Praxis
222
222
  value_type = attr_filters[:value_type]
223
223
  next unless value_type == Attributor::String
224
224
 
225
- value = item[:value]
226
- unless value.empty?
225
+ if item[:value].presence
227
226
  fuzzy_match = attr_filters[:fuzzy_match]
228
- if (value[-1] == '*' || value[0] == '*') && !fuzzy_match
227
+ if item[:fuzzy] && !item[:fuzzy].empty? && !fuzzy_match
229
228
  errors << "Fuzzy matching for #{attr_name} is not allowed (yet '*' was found in the value)"
230
229
  end
231
230
  end
@@ -15,41 +15,81 @@ module Praxis
15
15
  # Example: [{:name=>"multi"@0, :op=>"="@5}, {:value=>"1"@6}, {:value=>"2"@8}]
16
16
  def initialize(triad:, parent_group:)
17
17
  @parent_group = parent_group
18
-
19
18
  if triad.is_a? Array # several values coming in
20
19
  spec, *values = triad
21
20
  @name = spec[:name].to_sym
22
21
  @op = spec[:op].to_s
23
22
 
24
- @values = if values.empty?
25
- ""
23
+ if values.empty?
24
+ @values = ""
25
+ @fuzzies = nil
26
26
  elsif values.size == 1
27
- CGI.unescape(values.first[:value].to_s)
27
+ raw_val = values.first[:value].to_s
28
+ @values, @fuzzies = _compute_fuzzy(values.first[:value].to_s)
28
29
  else
29
- values.map{|e| CGI.unescape(e[:value].to_s)}
30
+ @values = []
31
+ @fuzzies = []
32
+ results = values.each do|e|
33
+ val, fuz = _compute_fuzzy(e[:value].to_s)
34
+ @values.push val
35
+ @fuzzies.push fuz
36
+ end
30
37
  end
31
38
  else # No values for the operand
32
39
  @name = triad[:name].to_sym
33
40
  @op = triad[:op].to_s
34
41
  if ['!','!!'].include?(@op)
35
- @values = nil
42
+ @values, @fuzzies = [nil, nil]
36
43
  else
37
44
  # Value operand without value? => convert it to empty string
38
45
  raise "Interesting, didn't know this could happen. Oops!" if triad[:value].is_a?(Array) && !triad[:value].empty?
39
- @values = (triad[:value] == []) ? '' : CGI.unescape(triad[:value].to_s) # TODO: could this be an array (or it always comes the other if)
46
+ if triad[:value] == []
47
+ @values, @fuzzies = ['', nil]
48
+ else
49
+ @values, @fuzzies = _compute_fuzzy(triad[:value].to_s)
50
+ end
40
51
  end
41
52
  end
42
53
  end
43
-
54
+ # Takes a raw val, and spits out the output val (unescaped), and the fuzzy definition
55
+ def _compute_fuzzy(raw_val)
56
+ starting = raw_val[0] == '*'
57
+ ending = raw_val[-1] == '*'
58
+ newval, fuzzy = if starting && ending
59
+ [raw_val[1..-2], :start_end]
60
+ elsif starting
61
+ [raw_val[1..-1], :start]
62
+ elsif ending
63
+ [raw_val[0..-2], :end]
64
+ else
65
+ [raw_val,nil]
66
+ end
67
+ newval = CGI.unescape(newval) if newval
68
+ [newval,fuzzy]
69
+ end
44
70
  def flattened_conditions
45
- [{name: @name, op: @op, values: @values, node_object: self}]
71
+ [{name: @name, op: @op, values: @values, fuzzies: @fuzzies, node_object: self}]
46
72
  end
47
73
 
74
+ # Dumps the value, marking where the fuzzy might be, and removing the * to differentiate from literals
75
+ def _dump_value(val,fuzzy)
76
+ case fuzzy
77
+ when nil
78
+ val
79
+ when :start_end
80
+ '{*}' + val + '{*}'
81
+ when :start
82
+ '{*}' + val
83
+ when :end
84
+ val +'{*}'
85
+ end
86
+ end
48
87
  def dump
49
88
  vals = if values.is_a? Array
50
- "[#{values.join(',')}]" # Purposedly enclose in brackets to make sure we differentiate
89
+ dumped = values.map.with_index{|val,i| _dump_value(val, @fuzzies[i])}
90
+ "[#{dumped.join(',')}]" # Purposedly enclose in brackets to make sure we differentiate
51
91
  else
52
- (values == '') ? "\"#{values}\"" : values # Dump the empty string explicitly with quotes if we've converted no value to empty string
92
+ (values == '') ? '""' : _dump_value(values,@fuzzies) # Dump the empty string explicitly with quotes if we've converted no value to empty string
53
93
  end
54
94
  "#{name}#{op}#{vals}"
55
95
  end
@@ -134,7 +174,7 @@ module Praxis
134
174
  end
135
175
 
136
176
  rule(:name) { match('[a-zA-Z0-9_\.]').repeat(1) } # TODO: are these the only characters that we allow for names?
137
- rule(:chars) { match('[^&|),]').repeat(0).as(:value) }
177
+ rule(:chars) { match('[^&|(),]').repeat(0).as(:value) }
138
178
  rule(:value) { chars >> (comma >> chars ).repeat }
139
179
 
140
180
  rule(:triad) {