praxis 2.0.pre.14 → 2.0.pre.18

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/bin/praxis +6 -0
  4. data/lib/praxis/action_definition.rb +2 -2
  5. data/lib/praxis/api_definition.rb +8 -4
  6. data/lib/praxis/blueprint.rb +22 -7
  7. data/lib/praxis/collection.rb +11 -0
  8. data/lib/praxis/dispatcher.rb +3 -3
  9. data/lib/praxis/docs/open_api/response_object.rb +21 -6
  10. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +63 -16
  11. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +1 -2
  12. data/lib/praxis/mapper/resource.rb +2 -2
  13. data/lib/praxis/mapper/selector_generator.rb +2 -2
  14. data/lib/praxis/media_type_identifier.rb +11 -1
  15. data/lib/praxis/request.rb +5 -0
  16. data/lib/praxis/request_stages/validate_params_and_headers.rb +0 -6
  17. data/lib/praxis/request_stages/validate_payload.rb +0 -1
  18. data/lib/praxis/response_definition.rb +46 -66
  19. data/lib/praxis/responses/http.rb +3 -1
  20. data/lib/praxis/tasks/routes.rb +6 -6
  21. data/lib/praxis/types/multipart_array.rb +14 -5
  22. data/lib/praxis/version.rb +1 -1
  23. data/praxis.gemspec +1 -1
  24. data/spec/praxis/action_definition_spec.rb +6 -3
  25. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +92 -34
  26. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +17 -2
  27. data/spec/praxis/extensions/support/spec_resources_active_model.rb +2 -0
  28. data/spec/praxis/mapper/resource_spec.rb +3 -3
  29. data/spec/praxis/mapper/selector_generator_spec.rb +34 -0
  30. data/spec/praxis/media_type_identifier_spec.rb +15 -1
  31. data/spec/praxis/request_spec.rb +3 -3
  32. data/spec/praxis/response_definition_spec.rb +37 -129
  33. data/spec/praxis/trait_spec.rb +3 -2
  34. data/spec/spec_app/design/media_types/instance.rb +1 -1
  35. data/spec/spec_app/design/resources/instances.rb +2 -2
  36. data/spec/spec_helper.rb +1 -0
  37. data/spec/support/spec_blueprints.rb +3 -3
  38. data/spec/support/spec_resources.rb +4 -0
  39. data/tasks/thor/templates/generator/example_app/app/v1/concerns/href.rb +33 -0
  40. data/tasks/thor/templates/generator/example_app/app/v1/resources/base.rb +4 -0
  41. data/tasks/thor/templates/generator/example_app/config/environment.rb +1 -1
  42. data/tasks/thor/templates/generator/scaffold/implementation/resources/item.rb +2 -2
  43. metadata +9 -8
@@ -7,14 +7,14 @@ namespace :praxis do
7
7
  table = Terminal::Table.new title: "Routes",
8
8
  headings: [
9
9
  "Version", "Path", "Verb",
10
- "Resource", "Action", "Implementation", "Options"
10
+ "Endpoint", "Action", "Implementation", "Options"
11
11
  ]
12
12
 
13
13
  rows = []
14
- Praxis::Application.instance.endpoint_definitions.each do |resource_definition|
15
- resource_definition.actions.each do |name, action|
14
+ Praxis::Application.instance.endpoint_definitions.each do |endpoint_definition|
15
+ endpoint_definition.actions.each do |name, action|
16
16
  method = begin
17
- m = resource_definition.controller.instance_method(name)
17
+ m = endpoint_definition.controller.instance_method(name)
18
18
  rescue
19
19
  nil
20
20
  end
@@ -22,13 +22,13 @@ namespace :praxis do
22
22
  method_name = method ? "#{method.owner.name}##{method.name}" : 'n/a'
23
23
 
24
24
  row = {
25
- resource: resource_definition.name,
25
+ resource: endpoint_definition.name,
26
26
  action: name,
27
27
  implementation: method_name,
28
28
  }
29
29
 
30
30
  unless action.route
31
- warn "Warning: No routes defined for #{resource_definition.name}##{name}."
31
+ warn "Warning: No routes defined for #{endpoint_definition.name}##{name}."
32
32
  rows << row
33
33
  else
34
34
  route = action.route
@@ -346,6 +346,10 @@ module Praxis
346
346
  end
347
347
  end
348
348
 
349
+ def part?(name)
350
+ self.any?{ |i| i.name == name }
351
+ end
352
+
349
353
  def validate(context=Attributor::DEFAULT_ROOT_CONTEXT)
350
354
  errors = self.each_with_index.each_with_object([]) do |(part, idx), errors|
351
355
  sub_context = if part.name
@@ -359,13 +363,18 @@ module Praxis
359
363
 
360
364
  self.class.attributes.each do |name, attribute|
361
365
  payload_attribute = attribute.options[:payload_attribute]
362
- next unless payload_attribute.options[:required]
363
- next if self.part(name)
364
366
 
365
- sub_context = self.class.generate_subcontext(context, name)
366
- errors.push *payload_attribute.validate_missing_value(sub_context)
367
+ if !self.part?(name)
368
+ if payload_attribute.options[:required]
369
+ sub_context = self.class.generate_subcontext(context, name)
370
+ errors.push "Attribute #{Attributor.humanize_context(sub_context)} is required"
371
+ end
372
+ # Return, don't bother checking nullability as it hasn't been provided
373
+ elsif !self.part(name) && !Attribute.nullable_attribute?(payload_attribute.options)
374
+ sub_context = self.class.generate_subcontext(context, name)
375
+ errors.push "Attribute #{Attributor.humanize_context(sub_context)} is not nullable"
376
+ end
367
377
  end
368
-
369
378
  errors
370
379
  end
371
380
 
@@ -1,3 +1,3 @@
1
1
  module Praxis
2
- VERSION = '2.0.pre.14'
2
+ VERSION = '2.0.pre.18'
3
3
  end
data/praxis.gemspec CHANGED
@@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
24
24
  spec.add_dependency 'mustermann', '>=1.1', '<=2'
25
25
  spec.add_dependency 'activesupport', '>= 3'
26
26
  spec.add_dependency 'mime', '~> 0'
27
- spec.add_dependency 'attributor', '>= 5.5'
27
+ spec.add_dependency 'attributor', '>= 6.0'
28
28
  spec.add_dependency 'thor'
29
29
  spec.add_dependency 'terminal-table', '~> 1.4'
30
30
 
@@ -40,7 +40,9 @@ describe Praxis::ActionDefinition do
40
40
 
41
41
  media_type media_type
42
42
  location location
43
- headers headers if headers
43
+ headers&.each do |(name, value)|
44
+ header(name, value)
45
+ end
44
46
  end
45
47
  end
46
48
  Praxis::ActionDefinition.new(:foo, endpoint_definition) do
@@ -151,15 +153,16 @@ describe Praxis::ActionDefinition do
151
153
  it 'includes the requirements in the param struct type' do
152
154
  errors = action.params.load(value).validate
153
155
  expect(errors).to have(1).item
154
- expect(errors.first).to match(/Key one is required/)
156
+ expect(errors.first).to match('Attribute $.key(:one) is required.')
155
157
  end
156
158
  end
157
159
 
158
160
  end
159
161
 
160
162
  describe '#payload' do
161
- it 'defaults to being required if omitted' do
163
+ it 'defaults to being required and non nullable if omitted' do
162
164
  expect(subject.payload.options[:required]).to be(true)
165
+ expect(subject.payload.options[:null]).to be(false)
163
166
  end
164
167
 
165
168
 
@@ -111,6 +111,48 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
111
111
  let(:filters_string) { 'tags.name=blue' }
112
112
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:tags).where('active_tags.name' => 'blue')
113
113
  end
114
+
115
+ context 'by just an association filter condition' do
116
+ context 'for a belongs_to association with NO ROWS' do
117
+ let(:filters_string) { 'category!!'}
118
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where.missing(:category)
119
+ end
120
+
121
+ context 'for a direct has_many association asking for missing rows' do
122
+ let(:filters_string) { 'primary_tags!!' }
123
+ it_behaves_like 'subject_equivalent_to',
124
+ ActiveBook.where.missing(:primary_tags)
125
+ end
126
+ context 'for a direct has_many association asking for non-missing rows' do
127
+ let(:filters_string) { 'primary_tags!' }
128
+ it_behaves_like 'subject_equivalent_to',
129
+ ActiveBook.left_outer_joins(:primary_tags).where.not('primary_tags.id' => nil)
130
+ end
131
+
132
+ context 'for a has_many through association with NO ROWS' do
133
+ let(:filters_string) { 'tags!!' }
134
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where.missing(:tags)
135
+ end
136
+
137
+ context 'for a has_many through association with SOME ROWS' do
138
+ let(:filters_string) { 'tags!' }
139
+ it_behaves_like 'subject_equivalent_to', ActiveBook.left_outer_joins(:tags).where.not('tags.id' => nil)
140
+ end
141
+
142
+ context 'for a 3 levels deep has_many association with NO ROWS' do
143
+ let(:filters_string) { 'category.books.taggings!!' }
144
+ it_behaves_like 'subject_equivalent_to',
145
+ ActiveBook.left_outer_joins(category: { books: :taggings }).where('category.books.taggings.id' => nil)
146
+ end
147
+
148
+ context 'for a 3 levels deep has_many association WITH SIME ROWS' do
149
+ let(:filters_string) { 'category.books.taggings!' }
150
+ it_behaves_like 'subject_equivalent_to',
151
+ ActiveBook.left_outer_joins(category: { books: :taggings }).where.not('category.books.taggings.id' => nil)
152
+ end
153
+
154
+ end
155
+
114
156
  end
115
157
 
116
158
  # NOTE: apparently AR when conditions are build with strings in the where clauses (instead of names, etc)
@@ -120,77 +162,77 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
120
162
  PREF = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
121
163
  COMMON_SQL_PREFIX = <<~SQL
122
164
  SELECT "active_books".* FROM "active_books"
123
- INNER JOIN
165
+ LEFT OUTER JOIN
124
166
  "active_authors" "#{PREF}/author" ON "#{PREF}/author"."id" = "active_books"."author_id"
125
167
  SQL
126
168
  context '=' do
127
169
  let(:filters_string) { 'author.id=11'}
128
170
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id = 11')
129
171
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
130
- WHERE ("#{PREF}/author"."id" = 11)
172
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" = 11)
131
173
  SQL
132
174
  end
133
175
  context '= (with array)' do
134
176
  let(:filters_string) { 'author.id=11,22'}
135
177
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id IN (11,22)')
136
178
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
137
- WHERE ("#{PREF}/author"."id" IN (11,22))
179
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" IN (11,22))
138
180
  SQL
139
181
  end
140
182
  context '!=' do
141
183
  let(:filters_string) { 'author.id!=11'}
142
184
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id <> 11')
143
185
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
144
- WHERE ("#{PREF}/author"."id" <> 11)
186
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" <> 11)
145
187
  SQL
146
188
  end
147
189
  context '!= (with array)' do
148
190
  let(:filters_string) { 'author.id!=11,888'}
149
191
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id NOT IN (11,888)')
150
192
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
151
- WHERE ("#{PREF}/author"."id" NOT IN (11,888))
193
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" NOT IN (11,888))
152
194
  SQL
153
195
  end
154
196
  context '>' do
155
197
  let(:filters_string) { 'author.id>1'}
156
198
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id > 1')
157
199
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
158
- WHERE ("#{PREF}/author"."id" > 1)
200
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" > 1)
159
201
  SQL
160
202
  end
161
203
  context '<' do
162
204
  let(:filters_string) { 'author.id<22'}
163
205
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id < 22')
164
206
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
165
- WHERE ("#{PREF}/author"."id" < 22)
207
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" < 22)
166
208
  SQL
167
209
  end
168
210
  context '>=' do
169
211
  let(:filters_string) { 'author.id>=22'}
170
212
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id >= 22')
171
213
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
172
- WHERE ("#{PREF}/author"."id" >= 22)
214
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" >= 22)
173
215
  SQL
174
216
  end
175
217
  context '<=' do
176
218
  let(:filters_string) { 'author.id<=22'}
177
219
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id <= 22')
178
220
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
179
- WHERE ("#{PREF}/author"."id" <= 22)
221
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" <= 22)
180
222
  SQL
181
223
  end
182
224
  context '!' do
183
225
  let(:filters_string) { 'author.id!'}
184
226
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id IS NOT NULL')
185
227
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
186
- WHERE ("#{PREF}/author"."id" IS NOT NULL)
228
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" IS NOT NULL)
187
229
  SQL
188
230
  end
189
231
  context '!!' do
190
232
  let(:filters_string) { 'author.name!!'}
191
233
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name IS NULL')
192
234
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
193
- WHERE ("#{PREF}/author"."name" IS NULL)
235
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."name" IS NULL)
194
236
  SQL
195
237
  end
196
238
  context 'including LIKE fuzzy queries' do
@@ -198,14 +240,14 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
198
240
  let(:filters_string) { 'author.name=author*'}
199
241
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name LIKE "author%"')
200
242
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
201
- WHERE ("#{PREF}/author"."name" LIKE 'author%')
243
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."name" LIKE 'author%')
202
244
  SQL
203
245
  end
204
246
  context 'NOT LIKE' do
205
247
  let(:filters_string) { 'author.name!=foobar*'}
206
248
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name NOT LIKE "foobar%"')
207
249
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
208
- WHERE ("#{PREF}/author"."name" NOT LIKE 'foobar%')
250
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."name" NOT LIKE 'foobar%')
209
251
  SQL
210
252
  end
211
253
  end
@@ -227,10 +269,10 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
227
269
  ActiveBook.joins(category: { books: :taggings }).where('active_taggings.tag_id': 1).where('active_taggings.label': 'primary')
228
270
  it_behaves_like 'subject_matches_sql', <<~SQL
229
271
  SELECT "active_books".* FROM "active_books"
230
- INNER JOIN "active_categories" ON "active_categories"."uuid" = "active_books"."category_uuid"
231
- INNER JOIN "active_books" "books_active_categories" ON "books_active_categories"."category_uuid" = "active_categories"."uuid"
232
- INNER JOIN "active_taggings" "#{PREF}/category/books/taggings" ON "/category/books/taggings"."book_id" = "books_active_categories"."id"
233
- WHERE ("#{PREF}/category/books/taggings"."tag_id" = 1)
272
+ LEFT OUTER JOIN "active_categories" ON "active_categories"."uuid" = "active_books"."category_uuid"
273
+ LEFT OUTER JOIN "active_books" "books_active_categories" ON "books_active_categories"."category_uuid" = "active_categories"."uuid"
274
+ LEFT OUTER JOIN "active_taggings" "#{PREF}/category/books/taggings" ON "/category/books/taggings"."book_id" = "books_active_categories"."id"
275
+ WHERE ("#{PREF}/category/books/taggings"."id" IS NOT NULL) AND ("#{PREF}/category/books/taggings"."tag_id" = 1)
234
276
  AND ("#{PREF}/category/books/taggings"."label" = 'primary')
235
277
  SQL
236
278
  end
@@ -260,14 +302,14 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
260
302
  .where('books_active_categories.simple_name': 'Book3')
261
303
  it_behaves_like 'subject_matches_sql', <<~SQL
262
304
  SELECT "active_books".* FROM "active_books"
263
- INNER JOIN "active_categories" ON "active_categories"."uuid" = "active_books"."category_uuid"
264
- INNER JOIN "active_books" "#{PREF}/category/books" ON "#{PREF}/category/books"."category_uuid" = "active_categories"."uuid"
305
+ LEFT OUTER JOIN "active_categories" ON "active_categories"."uuid" = "active_books"."category_uuid"
306
+ LEFT OUTER JOIN "active_books" "#{PREF}/category/books" ON "#{PREF}/category/books"."category_uuid" = "active_categories"."uuid"
265
307
  WHERE ("active_books"."simple_name" = 'Book1')
266
- AND ("#{PREF}/category/books"."simple_name" = 'Book3')
308
+ AND ("#{PREF}/category/books"."id" IS NOT NULL) AND ("#{PREF}/category/books"."simple_name" = 'Book3')
267
309
  SQL
268
310
  end
269
311
 
270
- context 'it qualifis them even if there are no joined tables/conditions at all' do
312
+ context 'it qualifies them even if there are no joined tables/conditions at all' do
271
313
  let(:filters_string) { 'id=11'}
272
314
  it_behaves_like 'subject_matches_sql', <<~SQL
273
315
  SELECT "active_books".* FROM "active_books"
@@ -310,20 +352,31 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
310
352
  .or(ActiveBook.joins(:taggings).where('active_taggings.tag_id' => 2))
311
353
  it_behaves_like 'subject_matches_sql', <<~SQL
312
354
  SELECT "active_books".* FROM "active_books"
313
- INNER JOIN "active_taggings" "/taggings" ON "/taggings"."book_id" = "active_books"."id"
314
- WHERE ("/taggings"."label" = 'primary' OR "/taggings"."tag_id" = 2)
355
+ LEFT OUTER JOIN "active_taggings" "/taggings" ON "/taggings"."book_id" = "active_books"."id"
356
+ WHERE ("/taggings"."id" IS NOT NULL) AND ("/taggings"."label" = 'primary' OR "/taggings"."tag_id" = 2)
357
+ SQL
358
+ end
359
+
360
+ context 'works well with ORs at a parent table along with joined associations with no rows' do
361
+ let(:filters_string) { 'name=Book1005|category!!' }
362
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where.missing(:category)
363
+ .or(ActiveBook.where.missing(:category).where(simple_name: "Book1005"))
364
+ it_behaves_like 'subject_matches_sql', <<~SQL
365
+ SELECT "active_books".* FROM "active_books"
366
+ LEFT OUTER JOIN "active_categories" "/category" ON "/category"."uuid" = "active_books"."category_uuid"
367
+ WHERE ("active_books"."simple_name" = 'Book1005' OR "/category"."uuid" IS NULL)
315
368
  SQL
316
369
  end
317
370
 
318
371
  context '3-deep AND and OR conditions' do
319
372
  let(:filters_string) { '(category.name=cat2|(taggings.label=primary&tags.name=red))&category_uuid=deadbeef1' }
320
373
  it_behaves_like('subject_equivalent_to', Proc.new do
321
- base=ActiveBook.joins(:category,:taggings,:tags)
374
+ base=ActiveBook.left_outer_joins(:category,:taggings,:tags)
322
375
 
323
- and1_or1 = base.where('category.name': 'cat2')
376
+ and1_or1 = base.where('category.name': 'cat2').where.not('category.uuid': nil)
324
377
 
325
- and1_or2_and1 = base.where('taggings.label': 'primary')
326
- and1_or2_and2 = base.where('tags.name': 'red')
378
+ and1_or2_and1 = base.where('taggings.label': 'primary').where.not('taggings.id': nil)
379
+ and1_or2_and2 = base.where('tags.name': 'red').where.not('tags.id': nil)
327
380
  and1_or2 = and1_or2_and1.and(and1_or2_and2)
328
381
 
329
382
  and1 = and1_or1.or(and1_or2)
@@ -334,11 +387,16 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
334
387
 
335
388
  it_behaves_like 'subject_matches_sql', <<~SQL
336
389
  SELECT "active_books".* FROM "active_books"
337
- INNER JOIN "active_categories" "/category" ON "/category"."uuid" = "active_books"."category_uuid"
338
- INNER JOIN "active_taggings" "/taggings" ON "/taggings"."book_id" = "active_books"."id"
339
- INNER JOIN "active_taggings" "taggings_active_books_join" ON "taggings_active_books_join"."book_id" = "active_books"."id"
340
- INNER JOIN "active_tags" "/tags" ON "/tags"."id" = "taggings_active_books_join"."tag_id"
341
- WHERE ("/category"."name" = 'cat2' OR ("/taggings"."label" = 'primary') AND ("/tags"."name" = 'red')) AND ("active_books"."category_uuid" = 'deadbeef1')
390
+ LEFT OUTER JOIN "active_categories" "/category" ON "/category"."uuid" = "active_books"."category_uuid"
391
+ LEFT OUTER JOIN "active_taggings" "/taggings" ON "/taggings"."book_id" = "active_books"."id"
392
+ LEFT OUTER JOIN "active_tags" "/tags" ON "/tags"."id" = "/taggings"."tag_id"
393
+ WHERE (("/category"."uuid" IS NOT NULL)
394
+ AND ("/category"."name" = 'cat2')
395
+ OR ("/taggings"."id" IS NOT NULL)
396
+ AND ("/taggings"."label" = 'primary')
397
+ AND ("/tags"."id" IS NOT NULL)
398
+ AND ("/tags"."name" = 'red'))
399
+ AND ("active_books"."category_uuid" = 'deadbeef1')
342
400
  SQL
343
401
  end
344
402
  end
@@ -391,9 +449,9 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
391
449
  # This is slightly incorrect in AR 6.1+ (since the picked aliases for active_taggings tables vary)
392
450
  # it_behaves_like 'subject_matches_sql', <<~SQL
393
451
  # SELECT "active_books".* FROM "active_books"
394
- # INNER JOIN "active_taggings" ON "active_taggings"."label" = 'primary'
452
+ # LEFT OUTER JOIN "active_taggings" ON "active_taggings"."label" = 'primary'
395
453
  # AND "active_taggings"."book_id" = "active_books"."id"
396
- # INNER JOIN "active_tags" "/primary_tags" ON "/primary_tags"."id" = "active_taggings"."tag_id"
454
+ # LEFT OUTER JOIN "active_tags" "/primary_tags" ON "/primary_tags"."id" = "active_taggings"."tag_id"
397
455
  # WHERE ("/primary_tags"."name" = 'blue')
398
456
  # SQL
399
457
  end
@@ -161,9 +161,9 @@ describe Praxis::Extensions::AttributeFiltering::FilteringParams do
161
161
  # construct it propertly by applying the block. Seems easier than creating the type alone, and
162
162
  # then manually apply the block
163
163
  Attributor::Attribute.new(described_class.for(Post)) do
164
- filter 'id', using: ['=', '!=']
164
+ filter 'id', using: ['=', '!=', '!']
165
165
  filter 'title', using: ['=', '!='], fuzzy: true
166
- filter 'content', using: ['=', '!=']
166
+ filter 'content', using: ['=', '!=', '!']
167
167
  end.type
168
168
  end
169
169
  let(:loaded_params) { filtering_params_type.load(filters_string) }
@@ -194,6 +194,21 @@ describe Praxis::Extensions::AttributeFiltering::FilteringParams do
194
194
  end
195
195
  end
196
196
  end
197
+
198
+ context 'non-valued operators' do
199
+ context 'for string typed fields' do
200
+ let(:filters_string) { 'content!'}
201
+ it 'validates properly' do
202
+ expect(subject).to be_empty
203
+ end
204
+ end
205
+ context 'for non-string typed fields' do
206
+ let(:filters_string) { 'id!'}
207
+ it 'validates properly' do
208
+ expect(subject).to be_empty
209
+ end
210
+ end
211
+ end
197
212
  context 'fuzzy matches' do
198
213
  context 'when allowed' do
199
214
  context 'given a fuzzy string' do
@@ -120,6 +120,8 @@ class ActiveBookResource < ActiveBaseResource
120
120
  'category.books.name': 'category.books.simple_name',
121
121
  'category.books.taggings.tag_id': 'category.books.taggings.tag_id',
122
122
  'category.books.taggings.label': 'category.books.taggings.label',
123
+ 'primary_tags': 'primary_tags',
124
+ 'category.books.taggings': 'category.books.taggings',
123
125
  )
124
126
  # Forces to add an extra column (added_column)...and yet another (author_id) that will serve
125
127
  # to check that if that's already automatically added due to an association, it won't interfere or duplicate
@@ -14,15 +14,15 @@ describe Praxis::Mapper::Resource do
14
14
  subject(:properties) { resource.properties }
15
15
 
16
16
  it 'includes directly-set properties' do
17
- expect(properties[:other_resource]).to eq(dependencies: [:other_model])
17
+ expect(properties[:other_resource]).to eq(dependencies: [:other_model], through: nil)
18
18
  end
19
19
 
20
20
  it 'inherits from a superclass' do
21
- expect(properties[:href]).to eq(dependencies: [:id])
21
+ expect(properties[:href]).to eq(dependencies: [:id], through: nil)
22
22
  end
23
23
 
24
24
  it 'properly overrides a property from the parent' do
25
- expect(properties[:name]).to eq(dependencies: [:simple_name])
25
+ expect(properties[:name]).to eq(dependencies: [:simple_name], through: nil)
26
26
  end
27
27
  end
28
28
  end
@@ -320,6 +320,40 @@ describe Praxis::Mapper::SelectorGenerator do
320
320
  it_behaves_like 'a proper selector'
321
321
  end
322
322
 
323
+ context 'that are several attriutes deep' do
324
+ let(:fields) { { deep_nested_deps: true } }
325
+ let(:selectors) do
326
+ {
327
+ model: SimpleModel,
328
+ columns: [:parent_id],
329
+ tracks: {
330
+ parent: {
331
+ model: ParentModel,
332
+ columns: [:id], # No FKs in the source model for one_to_many
333
+ tracks: {
334
+ simple_children: {
335
+ columns: [:parent_id, :other_model_id],
336
+ model: SimpleModel,
337
+ tracks: {
338
+ other_model: {
339
+ model: OtherModel,
340
+ columns: [:id, :parent_id],
341
+ tracks: {
342
+ parent: {
343
+ model: ParentModel,
344
+ columns: [:id, :simple_name, :other_attribute]
345
+ }
346
+ }
347
+ }
348
+ }
349
+ }
350
+ }
351
+ }
352
+ }
353
+ }
354
+ end
355
+ it_behaves_like 'a proper selector'
356
+ end
323
357
  end
324
358
  end
325
359
  end
@@ -218,7 +218,21 @@ describe Praxis::MediaTypeIdentifier do
218
218
 
219
219
  it 'replaces suffix and parameters and adds new ones' do
220
220
  expect(complex_subject + 'json; nuts=false; cherry=true').to \
221
- eq(described_class.new('application/vnd.icecream+json; cherry=true; nuts=false'))
221
+ eq(described_class.new('application/vnd.icecream+json; cherry=true; nuts=false'))
222
+ end
223
+
224
+ context 'does not add json for an already json identifier' do
225
+ it 'non-parameterized mediatypes simply ignore adding the suffix' do
226
+ plain_application_json = described_class.new('application/json')
227
+
228
+ expect(plain_application_json + 'json').to \
229
+ eq(plain_application_json)
230
+ end
231
+ it 'parameterized mediatypes still keeps them' do
232
+ parameterized_application_json = described_class.new('application/json; cherry=true; nuts=false')
233
+ expect(parameterized_application_json + 'json').to \
234
+ eq(parameterized_application_json)
235
+ end
222
236
  end
223
237
  end
224
238
  end
@@ -17,9 +17,9 @@ describe Praxis::Request do
17
17
 
18
18
  let(:context) do
19
19
  {
20
- params: [Attributor::AttributeResolver::ROOT_PREFIX, "params".freeze],
21
- headers: [Attributor::AttributeResolver::ROOT_PREFIX, "headers".freeze],
22
- payload: [Attributor::AttributeResolver::ROOT_PREFIX, "payload".freeze]
20
+ params: [Attributor::ROOT_PREFIX, "params".freeze],
21
+ headers: [Attributor::ROOT_PREFIX, "headers".freeze],
22
+ payload: [Attributor::ROOT_PREFIX, "payload".freeze]
23
23
  }.freeze
24
24
  end
25
25