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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/bin/praxis +6 -0
- data/lib/praxis/api_definition.rb +8 -4
- data/lib/praxis/collection.rb +11 -0
- data/lib/praxis/docs/open_api/response_object.rb +21 -6
- data/lib/praxis/extensions/attribute_filtering.rb +14 -1
- data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +81 -23
- data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +1 -1
- data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +3 -4
- data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +52 -12
- data/lib/praxis/mapper/resource.rb +2 -2
- data/lib/praxis/media_type_identifier.rb +11 -1
- data/lib/praxis/response_definition.rb +46 -66
- data/lib/praxis/responses/http.rb +3 -1
- data/lib/praxis/tasks/routes.rb +6 -6
- data/lib/praxis/version.rb +1 -1
- data/spec/praxis/action_definition_spec.rb +3 -1
- data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +110 -35
- data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +25 -3
- data/spec/praxis/extensions/attribute_filtering/filters_parser_spec.rb +13 -5
- data/spec/praxis/extensions/support/spec_resources_active_model.rb +2 -0
- data/spec/praxis/mapper/resource_spec.rb +3 -3
- data/spec/praxis/media_type_identifier_spec.rb +15 -1
- data/spec/praxis/response_definition_spec.rb +37 -129
- data/spec/spec_helper.rb +1 -0
- data/tasks/thor/templates/generator/example_app/app/v1/concerns/href.rb +33 -0
- data/tasks/thor/templates/generator/example_app/app/v1/resources/base.rb +4 -0
- data/tasks/thor/templates/generator/example_app/config/environment.rb +1 -1
- data/tasks/thor/templates/generator/scaffold/implementation/resources/item.rb +2 -2
- metadata +7 -6
@@ -46,8 +46,8 @@ module Praxis::Mapper
|
|
46
46
|
end
|
47
47
|
end
|
48
48
|
|
49
|
-
def self.property(name,
|
50
|
-
self.properties[name] =
|
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
|
-
|
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
|
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
|
-
|
69
|
-
|
61
|
+
header('Location', loc, description: description)
|
62
|
+
end
|
70
63
|
|
71
|
-
|
72
|
-
|
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(
|
86
|
-
case
|
87
|
-
when String
|
88
|
-
|
89
|
-
when
|
90
|
-
|
91
|
-
|
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
|
101
|
-
"
|
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
|
-
|
177
|
-
|
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
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
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
|
|
data/lib/praxis/tasks/routes.rb
CHANGED
@@ -7,14 +7,14 @@ namespace :praxis do
|
|
7
7
|
table = Terminal::Table.new title: "Routes",
|
8
8
|
headings: [
|
9
9
|
"Version", "Path", "Verb",
|
10
|
-
"
|
10
|
+
"Endpoint", "Action", "Implementation", "Options"
|
11
11
|
]
|
12
12
|
|
13
13
|
rows = []
|
14
|
-
Praxis::Application.instance.endpoint_definitions.each do |
|
15
|
-
|
14
|
+
Praxis::Application.instance.endpoint_definitions.each do |endpoint_definition|
|
15
|
+
endpoint_definition.actions.each do |name, action|
|
16
16
|
method = begin
|
17
|
-
m =
|
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:
|
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 #{
|
31
|
+
warn "Warning: No routes defined for #{endpoint_definition.name}##{name}."
|
32
32
|
rows << row
|
33
33
|
else
|
34
34
|
route = action.route
|
data/lib/praxis/version.rb
CHANGED
@@ -40,7 +40,9 @@ describe Praxis::ActionDefinition do
|
|
40
40
|
|
41
41
|
media_type media_type
|
42
42
|
location location
|
43
|
-
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
|
-
|
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
|
-
|
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
|
-
|
214
|
-
|
215
|
-
|
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
|
-
|
247
|
-
|
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
|
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
|
-
|
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.
|
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
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
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
|
-
#
|
452
|
+
# LEFT OUTER JOIN "active_taggings" ON "active_taggings"."label" = 'primary'
|
378
453
|
# AND "active_taggings"."book_id" = "active_books"."id"
|
379
|
-
#
|
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
|