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

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 (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
@@ -46,8 +46,8 @@ module Praxis::Mapper
46
46
  end
47
47
  end
48
48
 
49
- def self.property(name, **options)
50
- self.properties[name] = options
49
+ def self.property(name, dependencies: nil, through: nil)
50
+ self.properties[name] = {dependencies: dependencies, through: through}
51
51
  end
52
52
 
53
53
  def self._finalize!
@@ -198,10 +198,20 @@ module Praxis
198
198
  obj = self.class.new
199
199
  obj.type = self.type
200
200
  obj.subtype = self.subtype
201
- obj.suffix = suffix || self.suffix || ''
201
+ target_suffix = suffix || self.suffix
202
+ obj.suffix = redundant_suffix(target_suffix) ? '' : target_suffix
202
203
  obj.parameters = self.parameters.merge(parameters)
203
204
 
204
205
  obj
205
206
  end
207
+
208
+ def redundant_suffix(suffix)
209
+ # application/json does not need to be suffixed with +json (same for application/xml)
210
+ # we're supporting text/json and text/xml for older formats as well
211
+ if (self.type == 'application' || self.type == 'text') && self.subtype == suffix
212
+ return true
213
+ end
214
+ false
215
+ end
206
216
  end
207
217
  end
@@ -55,52 +55,36 @@ module Praxis
55
55
  end
56
56
  end
57
57
 
58
- def location(loc=nil)
59
- return @spec[:location] if loc.nil?
60
- unless ( loc.is_a?(Regexp) || loc.is_a?(String) )
61
- raise Exceptions::InvalidConfiguration.new(
62
- "Invalid location specification. Location in response must be either a regular expression or a string."
63
- )
64
- end
65
- @spec[:location] = loc
66
- end
58
+ def location(loc=nil, description: nil)
59
+ return headers.dig('Location',:value) if loc.nil?
67
60
 
68
- def headers(hdrs = nil)
69
- return @spec[:headers] if hdrs.nil?
61
+ header('Location', loc, description: description)
62
+ end
70
63
 
71
- case hdrs
72
- when Array
73
- hdrs.each {|header_name| header(header_name) }
74
- when Hash
75
- header(hdrs)
76
- when String
77
- header(hdrs)
78
- else
79
- raise Exceptions::InvalidConfiguration.new(
80
- "Invalid headers specification: Arrays, Hash, or String must be used. Received: #{hdrs.inspect}"
81
- )
82
- end
64
+ def headers
65
+ @spec[:headers]
83
66
  end
84
67
 
85
- def header(hdr)
86
- case hdr
87
- when String
88
- @spec[:headers][hdr] = true
89
- when Hash
90
- hdr.each do | k, v |
91
- unless v.is_a?(Regexp) || v.is_a?(String)
92
- raise Exceptions::InvalidConfiguration.new(
93
- "Header definitions for #{k.inspect} can only match values of type String or Regexp. Received: #{v.inspect}"
94
- )
95
- end
96
- @spec[:headers][k] = v
97
- end
68
+ def header(name, value, description: nil)
69
+ the_type, args = case value
70
+ when nil,String
71
+ [String, {}]
72
+ when Regexp
73
+ # A regexp means it's gonna be a String typed, attached to a regexp
74
+ [String, { regexp: value }]
98
75
  else
99
76
  raise Exceptions::InvalidConfiguration.new(
100
- "A header definition can only take a String (to match the name) or" +
101
- " a Hash (to match both the name and the value). Received: #{hdr.inspect}"
77
+ "A header definition for a response can only take String, Regexp or nil values (to match anything)." +
78
+ "Received the following value for header name #{name}: #{value}"
102
79
  )
103
80
  end
81
+
82
+ info = {
83
+ value: value,
84
+ attribute: Attributor::Attribute.new(the_type, **args)
85
+ }
86
+ info[:description] = description if description
87
+ @spec[:headers][name] = info
104
88
  end
105
89
 
106
90
  def example(context=nil)
@@ -123,13 +107,14 @@ module Praxis
123
107
  :status => status,
124
108
  :headers => {}
125
109
  }
126
- content[:location] = _describe_header(location) unless location == nil
127
110
 
128
111
  unless headers == nil
129
112
  headers.each do |name, value|
130
113
  content[:headers][name] = _describe_header(value)
131
114
  end
132
115
  end
116
+ content[:location] = content[:headers]['Location']
117
+
133
118
 
134
119
  if self.media_type
135
120
  payload = media_type.describe(true)
@@ -173,14 +158,14 @@ module Praxis
173
158
  end
174
159
 
175
160
  def _describe_header(data)
176
- data_type = data.is_a?(Regexp) ? :regexp : :string
177
- data_value = data.is_a?(Regexp) ? data.inspect : data
161
+
162
+ data_type = data[:value].is_a?(Regexp) ? :regexp : :string
163
+ data_value = data[:value].is_a?(Regexp) ? data[:value].inspect : data[:value]
178
164
  { :value => data_value, :type => data_type }
179
165
  end
180
166
 
181
167
  def validate(response, validate_body: false)
182
168
  validate_status!(response)
183
- validate_location!(response)
184
169
  validate_headers!(response)
185
170
  validate_content_type!(response)
186
171
  validate_parts!(response)
@@ -222,23 +207,13 @@ module Praxis
222
207
  end
223
208
  end
224
209
 
225
-
226
- # Validates 'Location' header
227
- #
228
- # @raise [Exceptions::Validation] When location header does not match to the defined one.
229
- #
230
- def validate_location!(response)
231
- return if location.nil? || location === response.headers['Location']
232
- raise Exceptions::Validation.new("LOCATION does not match #{location.inspect}")
233
- end
234
-
235
-
236
210
  # Validates Headers
237
211
  #
238
212
  # @raise [Exceptions::Validation] When there is a missing required header..
239
213
  #
240
214
  def validate_headers!(response)
241
215
  return unless headers
216
+
242
217
  headers.each do |name, value|
243
218
  if name.is_a? Symbol
244
219
  raise Exceptions::Validation.new(
@@ -252,20 +227,25 @@ module Praxis
252
227
  )
253
228
  end
254
229
 
255
- case value
256
- when String
257
- if response.headers[name] != value
258
- raise Exceptions::Validation.new(
259
- "Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name]}."
260
- )
261
- end
262
- when Regexp
263
- if response.headers[name] !~ value
264
- raise Exceptions::Validation.new(
265
- "Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name].inspect}."
266
- )
267
- end
230
+ errors = value[:attribute].validate(response.headers[name])
231
+
232
+ unless errors.empty?
233
+ raise Exceptions::Validation.new("Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name]}.")
268
234
  end
235
+ # case value
236
+ # when String
237
+ # if response.headers[name] != value
238
+ # raise Exceptions::Validation.new(
239
+ # "Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name]}."
240
+ # )
241
+ # end
242
+ # when Regexp
243
+ # if response.headers[name] !~ value
244
+ # raise Exceptions::Validation.new(
245
+ # "Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name].inspect}."
246
+ # )
247
+ # end
248
+ # end
269
249
  end
270
250
  end
271
251
 
@@ -160,7 +160,9 @@ module Praxis
160
160
 
161
161
  media_type media_type if media_type
162
162
  location location if location
163
- headers headers if headers
163
+ headers&.each do |(name, value)|
164
+ header(name: name, value: value)
165
+ end
164
166
  end
165
167
  end
166
168
 
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Praxis
2
- VERSION = '2.0.pre.13'
2
+ VERSION = '2.0.pre.17'
3
3
  end
@@ -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
@@ -71,7 +71,7 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
71
71
  end
72
72
  end
73
73
  end
74
- context 'that maps to a different name' do
74
+ context 'that maps to a different name' do
75
75
  let(:filters_string) { 'name=Book1'}
76
76
  it_behaves_like 'subject_equivalent_to', ActiveBook.where(simple_name: 'Book1')
77
77
  end
@@ -79,6 +79,23 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
79
79
  let(:filters_string) { 'fake_nested.name=Book1'}
80
80
  it_behaves_like 'subject_equivalent_to', ActiveBook.where(simple_name: 'Book1')
81
81
  end
82
+ context 'passing multiple values' do
83
+ context 'without fuzzy matching' do
84
+ let(:filters_string) { 'category_uuid=deadbeef1,deadbeef2' }
85
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: ['deadbeef1','deadbeef2'])
86
+ end
87
+ context 'with fuzzy matching' do
88
+ let(:filters_string) { 'category_uuid=*deadbeef1,deadbeef2*' }
89
+ it 'is not supported' do
90
+ expect{
91
+ subject
92
+ }.to raise_error(
93
+ Praxis::Extensions::AttributeFiltering::MultiMatchWithFuzzyNotAllowedByAdapter,
94
+ /Please use multiple OR clauses instead/
95
+ )
96
+ end
97
+ end
98
+ end
82
99
  end
83
100
 
84
101
  context 'by a field or a related model' do
@@ -94,6 +111,48 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
94
111
  let(:filters_string) { 'tags.name=blue' }
95
112
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:tags).where('active_tags.name' => 'blue')
96
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
+
97
156
  end
98
157
 
99
158
  # NOTE: apparently AR when conditions are build with strings in the where clauses (instead of names, etc)
@@ -103,77 +162,77 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
103
162
  PREF = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
104
163
  COMMON_SQL_PREFIX = <<~SQL
105
164
  SELECT "active_books".* FROM "active_books"
106
- INNER JOIN
165
+ LEFT OUTER JOIN
107
166
  "active_authors" "#{PREF}/author" ON "#{PREF}/author"."id" = "active_books"."author_id"
108
167
  SQL
109
168
  context '=' do
110
169
  let(:filters_string) { 'author.id=11'}
111
170
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id = 11')
112
171
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
113
- WHERE ("#{PREF}/author"."id" = 11)
172
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" = 11)
114
173
  SQL
115
174
  end
116
175
  context '= (with array)' do
117
176
  let(:filters_string) { 'author.id=11,22'}
118
177
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id IN (11,22)')
119
178
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
120
- WHERE ("#{PREF}/author"."id" IN (11,22))
179
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" IN (11,22))
121
180
  SQL
122
181
  end
123
182
  context '!=' do
124
183
  let(:filters_string) { 'author.id!=11'}
125
184
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id <> 11')
126
185
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
127
- WHERE ("#{PREF}/author"."id" <> 11)
186
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" <> 11)
128
187
  SQL
129
188
  end
130
189
  context '!= (with array)' do
131
190
  let(:filters_string) { 'author.id!=11,888'}
132
191
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id NOT IN (11,888)')
133
192
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
134
- WHERE ("#{PREF}/author"."id" NOT IN (11,888))
193
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" NOT IN (11,888))
135
194
  SQL
136
195
  end
137
196
  context '>' do
138
197
  let(:filters_string) { 'author.id>1'}
139
198
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id > 1')
140
199
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
141
- WHERE ("#{PREF}/author"."id" > 1)
200
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" > 1)
142
201
  SQL
143
202
  end
144
203
  context '<' do
145
204
  let(:filters_string) { 'author.id<22'}
146
205
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id < 22')
147
206
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
148
- WHERE ("#{PREF}/author"."id" < 22)
207
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" < 22)
149
208
  SQL
150
209
  end
151
210
  context '>=' do
152
211
  let(:filters_string) { 'author.id>=22'}
153
212
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id >= 22')
154
213
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
155
- WHERE ("#{PREF}/author"."id" >= 22)
214
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" >= 22)
156
215
  SQL
157
216
  end
158
217
  context '<=' do
159
218
  let(:filters_string) { 'author.id<=22'}
160
219
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id <= 22')
161
220
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
162
- WHERE ("#{PREF}/author"."id" <= 22)
221
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" <= 22)
163
222
  SQL
164
223
  end
165
224
  context '!' do
166
225
  let(:filters_string) { 'author.id!'}
167
226
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id IS NOT NULL')
168
227
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
169
- WHERE ("#{PREF}/author"."id" IS NOT NULL)
228
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."id" IS NOT NULL)
170
229
  SQL
171
230
  end
172
231
  context '!!' do
173
232
  let(:filters_string) { 'author.name!!'}
174
233
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name IS NULL')
175
234
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
176
- WHERE ("#{PREF}/author"."name" IS NULL)
235
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."name" IS NULL)
177
236
  SQL
178
237
  end
179
238
  context 'including LIKE fuzzy queries' do
@@ -181,14 +240,14 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
181
240
  let(:filters_string) { 'author.name=author*'}
182
241
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name LIKE "author%"')
183
242
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
184
- WHERE ("#{PREF}/author"."name" LIKE 'author%')
243
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."name" LIKE 'author%')
185
244
  SQL
186
245
  end
187
246
  context 'NOT LIKE' do
188
247
  let(:filters_string) { 'author.name!=foobar*'}
189
248
  it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name NOT LIKE "foobar%"')
190
249
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
191
- WHERE ("#{PREF}/author"."name" NOT LIKE 'foobar%')
250
+ WHERE ("#{PREF}/author"."id" IS NOT NULL) AND ("#{PREF}/author"."name" NOT LIKE 'foobar%')
192
251
  SQL
193
252
  end
194
253
  end
@@ -210,10 +269,10 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
210
269
  ActiveBook.joins(category: { books: :taggings }).where('active_taggings.tag_id': 1).where('active_taggings.label': 'primary')
211
270
  it_behaves_like 'subject_matches_sql', <<~SQL
212
271
  SELECT "active_books".* FROM "active_books"
213
- INNER JOIN "active_categories" ON "active_categories"."uuid" = "active_books"."category_uuid"
214
- INNER JOIN "active_books" "books_active_categories" ON "books_active_categories"."category_uuid" = "active_categories"."uuid"
215
- INNER JOIN "active_taggings" "#{PREF}/category/books/taggings" ON "/category/books/taggings"."book_id" = "books_active_categories"."id"
216
- 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)
217
276
  AND ("#{PREF}/category/books/taggings"."label" = 'primary')
218
277
  SQL
219
278
  end
@@ -243,14 +302,14 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
243
302
  .where('books_active_categories.simple_name': 'Book3')
244
303
  it_behaves_like 'subject_matches_sql', <<~SQL
245
304
  SELECT "active_books".* FROM "active_books"
246
- INNER JOIN "active_categories" ON "active_categories"."uuid" = "active_books"."category_uuid"
247
- 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"
248
307
  WHERE ("active_books"."simple_name" = 'Book1')
249
- AND ("#{PREF}/category/books"."simple_name" = 'Book3')
308
+ AND ("#{PREF}/category/books"."id" IS NOT NULL) AND ("#{PREF}/category/books"."simple_name" = 'Book3')
250
309
  SQL
251
310
  end
252
311
 
253
- 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
254
313
  let(:filters_string) { 'id=11'}
255
314
  it_behaves_like 'subject_matches_sql', <<~SQL
256
315
  SELECT "active_books".* FROM "active_books"
@@ -293,20 +352,31 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
293
352
  .or(ActiveBook.joins(:taggings).where('active_taggings.tag_id' => 2))
294
353
  it_behaves_like 'subject_matches_sql', <<~SQL
295
354
  SELECT "active_books".* FROM "active_books"
296
- INNER JOIN "active_taggings" "/taggings" ON "/taggings"."book_id" = "active_books"."id"
297
- 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)
298
368
  SQL
299
369
  end
300
370
 
301
371
  context '3-deep AND and OR conditions' do
302
372
  let(:filters_string) { '(category.name=cat2|(taggings.label=primary&tags.name=red))&category_uuid=deadbeef1' }
303
373
  it_behaves_like('subject_equivalent_to', Proc.new do
304
- base=ActiveBook.joins(:category,:taggings,:tags)
374
+ base=ActiveBook.left_outer_joins(:category,:taggings,:tags)
305
375
 
306
- and1_or1 = base.where('category.name': 'cat2')
376
+ and1_or1 = base.where('category.name': 'cat2').where.not('category.uuid': nil)
307
377
 
308
- and1_or2_and1 = base.where('taggings.label': 'primary')
309
- 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)
310
380
  and1_or2 = and1_or2_and1.and(and1_or2_and2)
311
381
 
312
382
  and1 = and1_or1.or(and1_or2)
@@ -317,11 +387,16 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
317
387
 
318
388
  it_behaves_like 'subject_matches_sql', <<~SQL
319
389
  SELECT "active_books".* FROM "active_books"
320
- INNER JOIN "active_categories" "/category" ON "/category"."uuid" = "active_books"."category_uuid"
321
- INNER JOIN "active_taggings" "/taggings" ON "/taggings"."book_id" = "active_books"."id"
322
- INNER JOIN "active_taggings" "taggings_active_books_join" ON "taggings_active_books_join"."book_id" = "active_books"."id"
323
- INNER JOIN "active_tags" "/tags" ON "/tags"."id" = "taggings_active_books_join"."tag_id"
324
- 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')
325
400
  SQL
326
401
  end
327
402
  end
@@ -374,9 +449,9 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
374
449
  # This is slightly incorrect in AR 6.1+ (since the picked aliases for active_taggings tables vary)
375
450
  # it_behaves_like 'subject_matches_sql', <<~SQL
376
451
  # SELECT "active_books".* FROM "active_books"
377
- # INNER JOIN "active_taggings" ON "active_taggings"."label" = 'primary'
452
+ # LEFT OUTER JOIN "active_taggings" ON "active_taggings"."label" = 'primary'
378
453
  # AND "active_taggings"."book_id" = "active_books"."id"
379
- # 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"
380
455
  # WHERE ("/primary_tags"."name" = 'blue')
381
456
  # SQL
382
457
  end