praxis 2.0.pre.11 → 2.0.pre.16

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/.ruby-version +1 -1
  3. data/CHANGELOG.md +22 -0
  4. data/bin/praxis +6 -0
  5. data/lib/praxis/api_definition.rb +8 -4
  6. data/lib/praxis/collection.rb +11 -0
  7. data/lib/praxis/docs/open_api/response_object.rb +21 -6
  8. data/lib/praxis/extensions/attribute_filtering.rb +14 -1
  9. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +154 -63
  10. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +3 -2
  11. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +46 -43
  12. data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +193 -0
  13. data/lib/praxis/mapper/resource.rb +2 -2
  14. data/lib/praxis/media_type_identifier.rb +11 -1
  15. data/lib/praxis/response_definition.rb +46 -66
  16. data/lib/praxis/responses/http.rb +3 -1
  17. data/lib/praxis/tasks/routes.rb +6 -6
  18. data/lib/praxis/version.rb +1 -1
  19. data/spec/praxis/action_definition_spec.rb +3 -1
  20. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +259 -172
  21. data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +25 -6
  22. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +117 -19
  23. data/spec/praxis/extensions/attribute_filtering/filters_parser_spec.rb +148 -0
  24. data/spec/praxis/mapper/resource_spec.rb +3 -3
  25. data/spec/praxis/media_type_identifier_spec.rb +15 -1
  26. data/spec/praxis/response_definition_spec.rb +37 -129
  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 +9 -6
@@ -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.11'
2
+ VERSION = '2.0.pre.16'
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
@@ -14,6 +14,7 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
14
14
  shared_examples 'subject_equivalent_to' do |expected_result|
15
15
  it do
16
16
  loaded_ids = subject.all.map(&:id).sort
17
+ expected_result = expected_result.call if expected_result.is_a?(Proc)
17
18
  expected_ids = expected_result.all.map(&:id).sort
18
19
  expect(loaded_ids).to_not be_empty
19
20
  expect(loaded_ids).to eq(expected_ids)
@@ -23,12 +24,11 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
23
24
  # Poorman's way to compare SQL queries...
24
25
  shared_examples 'subject_matches_sql' do |expected_sql|
25
26
  it do
26
- # Remove parenthesis as our queries have WHERE clauses using them...
27
- gen_sql = subject.all.to_sql.gsub(/[()]/,'')
27
+ gen_sql = subject.all.to_sql
28
28
  # Strip blank at the beggining (and end) of every line
29
29
  # ...and recompose it by adding an extra space at the beginning of each one instead
30
30
  exp = expected_sql.split(/\n/).map do |line|
31
- " " + line.strip.gsub(/[()]/,'')
31
+ " " + line.strip
32
32
  end.join.strip
33
33
  expect(gen_sql).to eq(exp)
34
34
  end
@@ -37,7 +37,7 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
37
37
  context 'initialize' do
38
38
  it 'sets the right things to the instance' do
39
39
  instance
40
- expect(instance.query).to eq(base_query)
40
+ expect(instance.instance_variable_get(:@initial_query)).to eq(base_query)
41
41
  expect(instance.model).to eq(base_model)
42
42
  expect(instance.filters_map).to eq(filters_map)
43
43
  end
@@ -52,208 +52,295 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
52
52
  expect(subject).to be(base_query)
53
53
  end
54
54
  end
55
- context 'by a simple field' do
56
- context 'that maps to the same name' do
57
- let(:filters_string) { 'category_uuid=deadbeef1' }
58
- it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: 'deadbeef1')
59
- end
60
- context 'same-name filter mapping works' do
61
- context 'even if ther was not a filter explicitly defined for it' do
55
+ context 'with flat AND conditions' do
56
+ context 'by a simple field' do
57
+ context 'that maps to the same name' do
62
58
  let(:filters_string) { 'category_uuid=deadbeef1' }
63
59
  it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: 'deadbeef1')
64
60
  end
61
+ context 'same-name filter mapping works' do
62
+ context 'even if ther was not a filter explicitly defined for it' do
63
+ let(:filters_string) { 'category_uuid=deadbeef1' }
64
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: 'deadbeef1')
65
+ end
65
66
 
66
- context 'but if it is a field that does not exist in the model' do
67
- let(:filters_string) { 'nonexisting=valuehere' }
68
- it 'it blows up with the right error' do
69
- expect{subject}.to raise_error(/Filtering by nonexisting is not allowed/)
67
+ context 'but if it is a field that does not exist in the model' do
68
+ let(:filters_string) { 'nonexisting=valuehere' }
69
+ it 'it blows up with the right error' do
70
+ expect{subject}.to raise_error(/Filtering by nonexisting is not allowed/)
71
+ end
70
72
  end
71
73
  end
72
- end
73
74
  context 'that maps to a different name' do
74
- let(:filters_string) { 'name=Book1'}
75
- it_behaves_like 'subject_equivalent_to', ActiveBook.where(simple_name: 'Book1')
76
- end
77
- context 'that is mapped as a nested struct' do
78
- let(:filters_string) { 'fake_nested.name=Book1'}
79
- it_behaves_like 'subject_equivalent_to', ActiveBook.where(simple_name: 'Book1')
75
+ let(:filters_string) { 'name=Book1'}
76
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where(simple_name: 'Book1')
77
+ end
78
+ context 'that is mapped as a nested struct' do
79
+ let(:filters_string) { 'fake_nested.name=Book1'}
80
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where(simple_name: 'Book1')
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
80
99
  end
81
- end
82
100
 
83
- context 'by a field or a related model' do
84
- context 'for a belongs_to association' do
85
- let(:filters_string) { 'author.name=author2'}
86
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name' => 'author2')
87
- end
88
- context 'for a has_many association' do
89
- let(:filters_string) { 'taggings.label=primary' }
90
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:taggings).where('active_taggings.label' => 'primary')
91
- end
92
- context 'for a has_many through association' do
93
- let(:filters_string) { 'tags.name=blue' }
94
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:tags).where('active_tags.name' => 'blue')
101
+ context 'by a field or a related model' do
102
+ context 'for a belongs_to association' do
103
+ let(:filters_string) { 'author.name=author2'}
104
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name' => 'author2')
105
+ end
106
+ context 'for a has_many association' do
107
+ let(:filters_string) { 'taggings.label=primary' }
108
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:taggings).where('active_taggings.label' => 'primary')
109
+ end
110
+ context 'for a has_many through association' do
111
+ let(:filters_string) { 'tags.name=blue' }
112
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:tags).where('active_tags.name' => 'blue')
113
+ end
95
114
  end
96
- end
97
115
 
98
- context 'by using all supported operators' do
99
- PREF = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
100
- COMMON_SQL_PREFIX = <<~SQL
101
- SELECT "active_books".* FROM "active_books"
102
- INNER JOIN
103
- "active_authors" "#{PREF}/author" ON "#{PREF}/author"."id" = "active_books"."author_id"
104
- SQL
105
- context '=' do
106
- let(:filters_string) { 'author.id=11'}
107
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id = 11')
108
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
109
- WHERE "#{PREF}/author"."id" = 11
110
- SQL
111
- end
112
- context '= (with array)' do
113
- let(:filters_string) { 'author.id=11,22'}
114
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id IN (11,22)')
115
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
116
- WHERE "#{PREF}/author"."id" IN (11,22)
117
- SQL
118
- end
119
- context '!=' do
120
- let(:filters_string) { 'author.id!=11'}
121
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id <> 11')
122
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
123
- WHERE "#{PREF}/author"."id" <> 11
124
- SQL
125
- end
126
- context '!= (with array)' do
127
- let(:filters_string) { 'author.id!=11,888'}
128
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id NOT IN (11,888)')
129
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
130
- WHERE "#{PREF}/author"."id" NOT IN (11,888)
131
- SQL
132
- end
133
- context '>' do
134
- let(:filters_string) { 'author.id>1'}
135
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id > 1')
136
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
137
- WHERE "#{PREF}/author"."id" > 1
138
- SQL
139
- end
140
- context '<' do
141
- let(:filters_string) { 'author.id<22'}
142
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id < 22')
143
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
144
- WHERE "#{PREF}/author"."id" < 22
145
- SQL
146
- end
147
- context '>=' do
148
- let(:filters_string) { 'author.id>=22'}
149
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id >= 22')
150
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
151
- WHERE "#{PREF}/author"."id" >= 22
152
- SQL
153
- end
154
- context '<=' do
155
- let(:filters_string) { 'author.id<=22'}
156
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id <= 22')
157
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
158
- WHERE "#{PREF}/author"."id" <= 22
159
- SQL
160
- end
161
- context '!' do
162
- let(:filters_string) { 'author.id!'}
163
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id IS NOT NULL')
164
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
165
- WHERE "#{PREF}/author"."id" IS NOT NULL
166
- SQL
167
- end
168
- context '!!' do
169
- let(:filters_string) { 'author.name!!'}
170
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name IS NULL')
171
- it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
172
- WHERE "#{PREF}/author"."name" IS NULL
173
- SQL
174
- end
175
- context 'including LIKE fuzzy queries' do
176
- context 'LIKE' do
177
- let(:filters_string) { 'author.name=author*'}
178
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name LIKE "author%"')
116
+ # NOTE: apparently AR when conditions are build with strings in the where clauses (instead of names, etc)
117
+ # it decides to parenthesize them, even when there's only 1 condition. Hence the silly parentization of
118
+ # these SQL fragments here (and others)
119
+ context 'by using all supported operators' do
120
+ PREF = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
121
+ COMMON_SQL_PREFIX = <<~SQL
122
+ SELECT "active_books".* FROM "active_books"
123
+ INNER JOIN
124
+ "active_authors" "#{PREF}/author" ON "#{PREF}/author"."id" = "active_books"."author_id"
125
+ SQL
126
+ context '=' do
127
+ let(:filters_string) { 'author.id=11'}
128
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id = 11')
129
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
130
+ WHERE ("#{PREF}/author"."id" = 11)
131
+ SQL
132
+ end
133
+ context '= (with array)' do
134
+ let(:filters_string) { 'author.id=11,22'}
135
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id IN (11,22)')
136
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
137
+ WHERE ("#{PREF}/author"."id" IN (11,22))
138
+ SQL
139
+ end
140
+ context '!=' do
141
+ let(:filters_string) { 'author.id!=11'}
142
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id <> 11')
143
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
144
+ WHERE ("#{PREF}/author"."id" <> 11)
145
+ SQL
146
+ end
147
+ context '!= (with array)' do
148
+ let(:filters_string) { 'author.id!=11,888'}
149
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id NOT IN (11,888)')
150
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
151
+ WHERE ("#{PREF}/author"."id" NOT IN (11,888))
152
+ SQL
153
+ end
154
+ context '>' do
155
+ let(:filters_string) { 'author.id>1'}
156
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id > 1')
157
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
158
+ WHERE ("#{PREF}/author"."id" > 1)
159
+ SQL
160
+ end
161
+ context '<' do
162
+ let(:filters_string) { 'author.id<22'}
163
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id < 22')
164
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
165
+ WHERE ("#{PREF}/author"."id" < 22)
166
+ SQL
167
+ end
168
+ context '>=' do
169
+ let(:filters_string) { 'author.id>=22'}
170
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id >= 22')
171
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
172
+ WHERE ("#{PREF}/author"."id" >= 22)
173
+ SQL
174
+ end
175
+ context '<=' do
176
+ let(:filters_string) { 'author.id<=22'}
177
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id <= 22')
179
178
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
180
- WHERE "#{PREF}/author"."name" LIKE 'author%'
181
- SQL
179
+ WHERE ("#{PREF}/author"."id" <= 22)
180
+ SQL
182
181
  end
183
- context 'NOT LIKE' do
184
- let(:filters_string) { 'author.name!=foobar*'}
185
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name NOT LIKE "foobar%"')
182
+ context '!' do
183
+ let(:filters_string) { 'author.id!'}
184
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.id IS NOT NULL')
186
185
  it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
187
- WHERE "#{PREF}/author"."name" NOT LIKE 'foobar%'
188
- SQL
186
+ WHERE ("#{PREF}/author"."id" IS NOT NULL)
187
+ SQL
188
+ end
189
+ context '!!' do
190
+ let(:filters_string) { 'author.name!!'}
191
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name IS NULL')
192
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
193
+ WHERE ("#{PREF}/author"."name" IS NULL)
194
+ SQL
195
+ end
196
+ context 'including LIKE fuzzy queries' do
197
+ context 'LIKE' do
198
+ let(:filters_string) { 'author.name=author*'}
199
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name LIKE "author%"')
200
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
201
+ WHERE ("#{PREF}/author"."name" LIKE 'author%')
202
+ SQL
203
+ end
204
+ context 'NOT LIKE' do
205
+ let(:filters_string) { 'author.name!=foobar*'}
206
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:author).where('active_authors.name NOT LIKE "foobar%"')
207
+ it_behaves_like 'subject_matches_sql', COMMON_SQL_PREFIX + <<~SQL
208
+ WHERE ("#{PREF}/author"."name" NOT LIKE 'foobar%')
209
+ SQL
210
+ end
189
211
  end
190
212
  end
191
- end
192
213
 
193
- context 'with a field mapping using a proc' do
194
- let(:filters_string) { 'name_is_not=Book1' }
195
- it_behaves_like 'subject_equivalent_to', ActiveBook.where.not(simple_name: 'Book1')
196
- end
214
+ context 'with a field mapping using a proc' do
215
+ let(:filters_string) { 'name_is_not=Book1' }
216
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where.not(simple_name: 'Book1')
217
+ end
197
218
 
198
- context 'with a deeply nested chains' do
199
- context 'of depth 2' do
200
- let(:filters_string) { 'category.books.name=Book2' }
201
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(category: :books).where('books_active_categories.simple_name': 'Book2')
219
+ context 'with a deeply nested chains' do
220
+ context 'of depth 2' do
221
+ let(:filters_string) { 'category.books.name=Book2' }
222
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(category: :books).where('books_active_categories.simple_name': 'Book2')
223
+ end
224
+ context 'multiple conditions on a nested relationship' do
225
+ let(:filters_string) { 'category.books.taggings.tag_id=1&category.books.taggings.label=primary' }
226
+ it_behaves_like 'subject_equivalent_to',
227
+ ActiveBook.joins(category: { books: :taggings }).where('active_taggings.tag_id': 1).where('active_taggings.label': 'primary')
228
+ it_behaves_like 'subject_matches_sql', <<~SQL
229
+ 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)
234
+ AND ("#{PREF}/category/books/taggings"."label" = 'primary')
235
+ SQL
236
+ end
237
+ context 'that contain multiple joins to the same table' do
238
+ let(:filters_string) { 'taggings.tag.taggings.tag_id=1' }
239
+ it_behaves_like 'subject_equivalent_to',
240
+ ActiveBook.joins(taggings: {tag: :taggings}).where('taggings_active_tags.tag_id=1')
241
+ end
202
242
  end
203
- context 'multiple conditions on a nested relationship' do
204
- let(:filters_string) { 'category.books.taggings.tag_id=1&category.books.taggings.label=primary' }
205
- it_behaves_like 'subject_equivalent_to',
206
- ActiveBook.joins(category: { books: :taggings }).where('active_taggings.tag_id': 1).where('active_taggings.label': 'primary')
207
- it_behaves_like 'subject_matches_sql', <<~SQL
208
- SELECT "active_books".* FROM "active_books"
209
- INNER JOIN "active_categories" ON "active_categories"."uuid" = "active_books"."category_uuid"
210
- INNER JOIN "active_books" "books_active_categories" ON "books_active_categories"."category_uuid" = "active_categories"."uuid"
211
- INNER JOIN "active_taggings" "#{PREF}/category/books/taggings" ON "/category/books/taggings"."book_id" = "books_active_categories"."id"
212
- WHERE ("#{PREF}/category/books/taggings"."tag_id" = 1)
213
- AND ("#{PREF}/category/books/taggings"."label" = 'primary')
214
- SQL
243
+
244
+ context 'by multiple fields' do
245
+ context 'adds the where clauses for the top model if fields belong to it' do
246
+ let(:filters_string) { 'category_uuid=deadbeef1&name=Book1' }
247
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: 'deadbeef1', simple_name: 'Book1')
248
+ end
249
+ context 'adds multiple where clauses for same nested relationship join (instead of multiple joins with 1 clause each)' do
250
+ let(:filters_string) { 'taggings.label=primary&taggings.tag_id=2' }
251
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:taggings).where('active_taggings.label' => 'primary', 'active_taggings.tag_id' => 2)
252
+ end
215
253
  end
216
- context 'that contain multiple joins to the same table' do
217
- let(:filters_string) { 'taggings.tag.taggings.tag_id=1' }
218
- it_behaves_like 'subject_equivalent_to',
219
- ActiveBook.joins(taggings: {tag: :taggings}).where('taggings_active_tags.tag_id=1')
254
+
255
+ context 'uses fully qualified names for conditions (disambiguate fields)' do
256
+ context 'when we have a join table condition that has the same field' do
257
+ let(:filters_string) { 'name=Book1&category.books.name=Book3' }
258
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(category: :books)
259
+ .where('simple_name': 'Book1')
260
+ .where('books_active_categories.simple_name': 'Book3')
261
+ it_behaves_like 'subject_matches_sql', <<~SQL
262
+ 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"
265
+ WHERE ("active_books"."simple_name" = 'Book1')
266
+ AND ("#{PREF}/category/books"."simple_name" = 'Book3')
267
+ SQL
268
+ end
269
+
270
+ context 'it qualifis them even if there are no joined tables/conditions at all' do
271
+ let(:filters_string) { 'id=11'}
272
+ it_behaves_like 'subject_matches_sql', <<~SQL
273
+ SELECT "active_books".* FROM "active_books"
274
+ WHERE ("active_books"."id" = 11)
275
+ SQL
276
+ end
277
+
220
278
  end
221
279
  end
222
280
 
223
- context 'by multiple fields' do
281
+ context 'with simple OR conditions' do
224
282
  context 'adds the where clauses for the top model if fields belong to it' do
225
- let(:filters_string) { 'category_uuid=deadbeef1&name=Book1' }
226
- it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: 'deadbeef1', simple_name: 'Book1')
283
+ let(:filters_string) { 'category_uuid=deadbeef1|name=Book1' }
284
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: 'deadbeef1').or(ActiveBook.where(simple_name: 'Book1'))
285
+ end
286
+ context 'supports top level parenthesis' do
287
+ let(:filters_string) { '(category_uuid=deadbeef1|name=Book1)' }
288
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: 'deadbeef1').or(ActiveBook.where(simple_name: 'Book1'))
227
289
  end
228
290
  context 'adds multiple where clauses for same nested relationship join (instead of multiple joins with 1 clause each)' do
229
- let(:filters_string) { 'taggings.label=primary&taggings.tag_id=2' }
230
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:taggings).where('active_taggings.label' => 'primary', 'active_taggings.tag_id' => 2)
291
+ let(:filters_string) { 'taggings.label=primary|taggings.tag_id=2' }
292
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:taggings).where('active_taggings.label' => 'primary')
293
+ .or(ActiveBook.joins(:taggings).where('active_taggings.tag_id' => 2))
231
294
  end
232
295
  end
233
296
 
234
- context 'uses fully qualified names for conditions (disambiguate fields)' do
235
- context 'when we have a join table condition that has the same field' do
236
- let(:filters_string) { 'name=Book1&category.books.name=Book3' }
237
- it_behaves_like 'subject_equivalent_to', ActiveBook.joins(category: :books)
238
- .where('simple_name': 'Book1')
239
- .where('books_active_categories.simple_name': 'Book3')
240
- it_behaves_like 'subject_matches_sql', <<~SQL
241
- SELECT "active_books".* FROM "active_books"
242
- INNER JOIN "active_categories" ON "active_categories"."uuid" = "active_books"."category_uuid"
243
- INNER JOIN "active_books" "#{PREF}/category/books" ON "#{PREF}/category/books"."category_uuid" = "active_categories"."uuid"
244
- WHERE ("#{PREF}/category/books"."simple_name" = 'Book3')
245
- AND ("active_books"."simple_name" = 'Book1')
246
- SQL
247
- end
297
+ context 'with combined AND and OR conditions' do
298
+ let(:filters_string) { '(category_uuid=deadbeef1|category_uuid=deadbeef2)&(name=Book1|name=Book2)' }
299
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: 'deadbeef1').or(ActiveBook.where(category_uuid: 'deadbeef2'))
300
+ .and(ActiveBook.where(simple_name: 'Book1').or(ActiveBook.where(simple_name: 'Book2')))
301
+ it_behaves_like 'subject_matches_sql', <<~SQL
302
+ SELECT "active_books".* FROM "active_books"
303
+ WHERE ("active_books"."category_uuid" = 'deadbeef1' OR "active_books"."category_uuid" = 'deadbeef2')
304
+ AND ("active_books"."simple_name" = 'Book1' OR "active_books"."simple_name" = 'Book2')
305
+ SQL
248
306
 
249
- context 'it qualifis them even if there are no joined tables/conditions at all' do
250
- let(:filters_string) { 'id=11'}
307
+ context 'adds multiple where clauses for same nested relationship join (instead of multiple joins with 1 clause each)' do
308
+ let(:filters_string) { 'taggings.label=primary|taggings.tag_id=2' }
309
+ it_behaves_like 'subject_equivalent_to', ActiveBook.joins(:taggings).where('active_taggings.label' => 'primary')
310
+ .or(ActiveBook.joins(:taggings).where('active_taggings.tag_id' => 2))
251
311
  it_behaves_like 'subject_matches_sql', <<~SQL
252
312
  SELECT "active_books".* FROM "active_books"
253
- WHERE "active_books"."id" = 11
254
- SQL
313
+ INNER JOIN "active_taggings" "/taggings" ON "/taggings"."book_id" = "active_books"."id"
314
+ WHERE ("/taggings"."label" = 'primary' OR "/taggings"."tag_id" = 2)
315
+ SQL
255
316
  end
256
317
 
318
+ context '3-deep AND and OR conditions' do
319
+ let(:filters_string) { '(category.name=cat2|(taggings.label=primary&tags.name=red))&category_uuid=deadbeef1' }
320
+ it_behaves_like('subject_equivalent_to', Proc.new do
321
+ base=ActiveBook.joins(:category,:taggings,:tags)
322
+
323
+ and1_or1 = base.where('category.name': 'cat2')
324
+
325
+ and1_or2_and1 = base.where('taggings.label': 'primary')
326
+ and1_or2_and2 = base.where('tags.name': 'red')
327
+ and1_or2 = and1_or2_and1.and(and1_or2_and2)
328
+
329
+ and1 = and1_or1.or(and1_or2)
330
+ and2=base.where(category_uuid: 'deadbeef1')
331
+
332
+ query = and1.and(and2)
333
+ end)
334
+
335
+ it_behaves_like 'subject_matches_sql', <<~SQL
336
+ 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')
342
+ SQL
343
+ end
257
344
  end
258
345
 
259
346
  context 'ActiveRecord continues to work as expected (with our patches)' do