standardapi 6.1.0 → 7.1.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +80 -58
  3. data/lib/standard_api/access_control_list.rb +40 -6
  4. data/lib/standard_api/controller.rb +96 -28
  5. data/lib/standard_api/helpers.rb +22 -22
  6. data/lib/standard_api/middleware.rb +5 -0
  7. data/lib/standard_api/railtie.rb +17 -0
  8. data/lib/standard_api/route_helpers.rb +59 -9
  9. data/lib/standard_api/test_case/calculate_tests.rb +7 -6
  10. data/lib/standard_api/test_case/destroy_tests.rb +19 -7
  11. data/lib/standard_api/test_case/index_tests.rb +7 -13
  12. data/lib/standard_api/test_case/show_tests.rb +7 -7
  13. data/lib/standard_api/test_case/update_tests.rb +7 -6
  14. data/lib/standard_api/version.rb +1 -1
  15. data/lib/standard_api/views/application/_record.json.jbuilder +17 -6
  16. data/lib/standard_api/views/application/_record.streamer +4 -3
  17. data/lib/standard_api/views/application/_schema.json.jbuilder +20 -8
  18. data/lib/standard_api/views/application/_schema.streamer +22 -8
  19. data/lib/standard_api.rb +1 -0
  20. data/test/standard_api/caching_test.rb +2 -2
  21. data/test/standard_api/controller/include_test.rb +107 -0
  22. data/test/standard_api/controller/subresource_test.rb +157 -0
  23. data/test/standard_api/helpers_test.rb +9 -8
  24. data/test/standard_api/nested_attributes/belongs_to_test.rb +71 -0
  25. data/test/standard_api/nested_attributes/has_and_belongs_to_many_test.rb +70 -0
  26. data/test/standard_api/nested_attributes/has_many_test.rb +85 -0
  27. data/test/standard_api/nested_attributes/has_one_test.rb +71 -0
  28. data/test/standard_api/route_helpers_test.rb +56 -0
  29. data/test/standard_api/standard_api_test.rb +110 -50
  30. data/test/standard_api/test_app/app/controllers/acl/account_acl.rb +5 -1
  31. data/test/standard_api/test_app/app/controllers/acl/camera_acl.rb +7 -0
  32. data/test/standard_api/test_app/app/controllers/acl/photo_acl.rb +13 -0
  33. data/test/standard_api/test_app/app/controllers/acl/property_acl.rb +7 -1
  34. data/test/standard_api/test_app/controllers.rb +17 -0
  35. data/test/standard_api/test_app/models.rb +59 -2
  36. data/test/standard_api/test_app/test/factories.rb +3 -0
  37. data/test/standard_api/test_app/views/sessions/create.json.jbuilder +1 -0
  38. data/test/standard_api/test_app/views/sessions/create.streamer +3 -0
  39. data/test/standard_api/test_app.rb +13 -1
  40. data/test/standard_api/test_helper.rb +100 -7
  41. metadata +52 -13
@@ -92,19 +92,34 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
92
92
  assert_equal @controller.send(:model_includes), []
93
93
  end
94
94
 
95
- test 'Controller#model_params defaults to []' do
95
+ test 'Controller#model_params defaults to ActionController::Parameters' do
96
+ @controller = DocumentsController.new
97
+ @controller.params = ActionController::Parameters.new
98
+ assert_equal @controller.send(:model_params), ActionController::Parameters.new
99
+ end
100
+
101
+ test 'Controller#model_params defaults to ActionController::Parameters when no resource_attributes' do
96
102
  @controller = ReferencesController.new
97
- @controller.params = {}
103
+ @controller.params = ActionController::Parameters.new
98
104
  assert_equal @controller.send(:model_params), ActionController::Parameters.new
99
105
  end
100
106
 
101
- test 'Controller#current_mask' do
107
+ test 'Controller#mask' do
102
108
  @controller = ReferencesController.new
103
- @controller.instance_variable_set('@current_mask', { 'references' => { 'subject_id' => 1 }})
109
+ @controller.define_singleton_method(:mask_for) do |table_name|
110
+ {subject_id: 1}
111
+ end
104
112
  @controller.params = {}
105
113
  assert_equal 'SELECT "references".* FROM "references" WHERE "references"."subject_id" = 1', @controller.send(:resources).to_sql
106
114
  end
107
115
 
116
+ test "Auto includes on a controller without a model" do
117
+ @controller = SessionsController.new
118
+ assert_nil @controller.send(:model)
119
+ post sessions_path(format: :json), params: {session: {user: 'user', pass: 'pass'}}
120
+ assert_response :ok
121
+ end
122
+
108
123
  test 'ApplicationController#schema.json' do
109
124
  get schema_path(format: 'json')
110
125
 
@@ -112,7 +127,7 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
112
127
  controllers = ApplicationController.descendants
113
128
  controllers.select! { |c| c.ancestors.include?(StandardAPI::Controller) && c != StandardAPI::Controller }
114
129
 
115
- @controller.send(:models).reject { |x| x.name == 'Photo' }.each do |model|
130
+ @controller.send(:models).reject { |x| %w(Photo Document).include?(x.name) }.each do |model|
116
131
  assert_equal true, schema['models'].has_key?(model.name)
117
132
 
118
133
  model_comment = model.connection.table_comment(model.table_name)
@@ -125,7 +140,7 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
125
140
  model.columns.each do |column|
126
141
  assert_equal json_column_type(column.sql_type), schema.dig('models', model.name, 'attributes', column.name, 'type')
127
142
  default = column.default
128
- if default then
143
+ if default
129
144
  default = model.connection.lookup_cast_type_from_column(column).deserialize(default)
130
145
  assert_equal default, schema.dig('models', model.name, 'attributes', column.name, 'default')
131
146
  else
@@ -134,11 +149,15 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
134
149
  assert_equal column.name == model.primary_key, schema.dig('models', model.name, 'attributes', column.name, 'primary_key')
135
150
  assert_equal column.null, schema.dig('models', model.name, 'attributes', column.name, 'null')
136
151
  assert_equal column.array, schema.dig('models', model.name, 'attributes', column.name, 'array')
137
- if column.comment then
152
+ if column.comment
138
153
  assert_equal column.comment, schema.dig('models', model.name, 'attributes', column.name, 'comment')
139
154
  else
140
155
  assert_nil schema.dig('models', model.name, 'attributes', column.name, 'comment')
141
156
  end
157
+
158
+ if column.respond_to?(:auto_populated?)
159
+ assert_equal !!column.auto_populated?, schema.dig('models', model.name, 'attributes', column.name, 'auto_populated')
160
+ end
142
161
  end
143
162
  end
144
163
 
@@ -163,6 +182,26 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
163
182
  assert_nil schema['limit']
164
183
  end
165
184
 
185
+ test 'Controller#schema.json for an enum with default' do
186
+ get schema_documents_path(format: 'json')
187
+
188
+ schema = JSON(response.body)
189
+
190
+ assert_equal true, schema.has_key?('attributes')
191
+ assert_equal 'string', schema['attributes']['level']['type']
192
+ assert_equal 'public', schema['attributes']['level']['default']
193
+ end
194
+
195
+ test 'Controller#schema.json for an enum without default' do
196
+ get schema_documents_path(format: 'json')
197
+
198
+ schema = JSON(response.body)
199
+
200
+ assert_equal true, schema.has_key?('attributes')
201
+ assert_equal 'string', schema['attributes']['rating']['type']
202
+ assert_nil schema['attributes']['rating']['default']
203
+ end
204
+
166
205
  test 'Controller#index w/o limit' do
167
206
  account = create(:account)
168
207
  get unlimited_index_path(format: 'json')
@@ -188,47 +227,6 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
188
227
  assert_redirected_to document_path(pdf)
189
228
  end
190
229
 
191
- test 'Controller#add_resource' do
192
- property = create(:property, photos: [])
193
- photo = create(:photo)
194
-
195
- post "/properties/#{property.id}/photos/#{photo.id}"
196
- assert_equal property.photos.reload.map(&:id), [photo.id]
197
- assert_response :created
198
-
199
- post "/properties/#{property.id}/photos/9999999"
200
- assert_response :not_found
201
- end
202
-
203
- test 'Controller#add_resource with has_one' do
204
- photo = create(:document)
205
- property = create(:property)
206
- post "/properties/#{property.id}/document/#{photo.id}"
207
- assert_equal property.reload.document, photo
208
- assert_response :created
209
- end
210
-
211
- test 'Controller#remove_resource' do
212
- photo = create(:photo)
213
- property = create(:property, photos: [photo])
214
- assert_equal property.photos.reload, [photo]
215
- delete "/properties/#{property.id}/photos/#{photo.id}"
216
- assert_equal property.photos.reload, []
217
- assert_response :no_content
218
-
219
- delete "/properties/#{property.id}/photos/9999999"
220
- assert_response :not_found
221
- end
222
-
223
- test 'Controller#remove_resource with has_one' do
224
- photo = create(:document)
225
- property = create(:property, document: photo)
226
- assert_equal property.document, photo
227
- delete "/properties/#{property.id}/document/#{photo.id}"
228
- assert_nil property.reload.document
229
- assert_response :no_content
230
- end
231
-
232
230
  # = View Tests
233
231
 
234
232
  test 'rendering tables' do
@@ -236,7 +234,7 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
236
234
  assert_response :ok
237
235
  # assert_equal ['properties', 'accounts', 'photos', 'references', 'sessions', 'unlimited'], response.parsed_body
238
236
  # Multiple 'accounts' because multiple controllers with that model for testing.
239
- assert_equal ["properties", "accounts", "documents", "photos", "references", "accounts", 'accounts'].sort, response.parsed_body.sort
237
+ assert_equal ["properties", "accounts", "documents", "photos", "references", "accounts", 'accounts', 'uuid_models'].sort, response.parsed_body.sort
240
238
  end
241
239
 
242
240
  test 'rendering null attribute' do
@@ -246,6 +244,19 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
246
244
  assert_nil JSON(response.body)['landlord']
247
245
  end
248
246
 
247
+ test 'rendering binary attribute' do
248
+ reference = create(:reference, sha: "Hello World")
249
+ get reference_path(reference, format: 'json'), params: { id: reference.id }
250
+ assert_equal "48656c6c6f20576f726c64", JSON(response.body)['sha']
251
+ end
252
+
253
+ test 'rendering a custom binary attribute' do
254
+ reference = create(:reference, custom_binary: 2)
255
+ get reference_path(reference, format: 'json'), params: { id: reference.id }
256
+ assert_equal 2, JSON(response.body)['custom_binary']
257
+ assert_equal "\\x00000002".b,reference.custom_binary_before_type_cast
258
+ end
259
+
249
260
  test 'rendering null attribute for has_one through' do
250
261
  property = create(:property)
251
262
  get property_path(property, format: 'json'), params: { id: property.id, include: [:document] }
@@ -253,6 +264,25 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
253
264
  assert_nil JSON(response.body)['document']
254
265
  end
255
266
 
267
+ test 'rendering serialize_attribute' do
268
+ property = create(:property, description: 'This text will magically change')
269
+ get property_path(property, format: 'json'), params: { id: property.id, magic: true }
270
+
271
+ body = JSON(response.body)
272
+ assert_equal body['description'], 'See it changed!'
273
+ end
274
+
275
+ test 'rendering an enum' do
276
+ public_document = create(:document, level: 'public')
277
+
278
+ get documents_path(format: 'json'), params: { limit: 1 }
279
+ assert_equal JSON(response.body)[0]['level'], 'public'
280
+
281
+ secret_document = create(:document, level: 'secret')
282
+ get document_path(secret_document, format: 'json')
283
+ assert_equal JSON(response.body)['level'], 'secret'
284
+ end
285
+
256
286
  test '#index.json uses overridden partial' do
257
287
  create(:property, photos: [create(:photo)])
258
288
  get properties_path(format: 'json'), params: { limit: 100, include: [{:photos => { order: :id }}] }
@@ -444,7 +474,7 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
444
474
  },
445
475
  format: 'json')
446
476
 
447
- assert_equal [photos.first.id], JSON(response.body)['photos'].map { |x| x['id'] }
477
+ assert_equal [photos.map(&:id).sort.first], JSON(response.body)['photos'].map { |x| x['id'] }
448
478
 
449
479
  get property_path(property,
450
480
  include: {
@@ -696,4 +726,34 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
696
726
  assert_equal [1], JSON(response.body)
697
727
  end
698
728
 
729
+ test 'preloading polymorphic associations' do
730
+ p1 = create(:property)
731
+ p2 = create(:property)
732
+ c1 = create(:camera)
733
+ c2 = create(:camera)
734
+ a1 = create(:account, subject: p1, subject_cached_at: Time.now)
735
+ a2 = create(:account, subject: p2, subject_cached_at: Time.now)
736
+ a3 = create(:account, subject: c1, subject_cached_at: Time.now)
737
+ a4 = create(:account, subject: c2, subject_cached_at: Time.now)
738
+ a5 = create(:account, subject: c2, subject_cached_at: Time.now)
739
+
740
+ assert_sql(
741
+ 'SELECT "properties".* FROM "properties" WHERE "properties"."id" IN ($1, $2)',
742
+ 'SELECT "cameras".* FROM "cameras" WHERE "cameras"."id" IN ($1, $2)'
743
+ ) do
744
+ assert_no_sql("SELECT \"properties\".* FROM \"properties\" WHERE \"properties\".\"id\" = $1 LIMIT $2") do
745
+ get accounts_path(limit: 10, include: { subject: { landlord: { when: { subject_type: 'Property' } } } }, format: 'json')
746
+
747
+ assert_equal p1.id, a1.subject_id
748
+ assert_equal p2.id, a2.subject_id
749
+ assert_equal c1.id, a3.subject_id
750
+ assert_equal p1.id, JSON(response.body).dig(0, 'subject', 'id')
751
+ assert_equal p2.id, JSON(response.body).dig(1, 'subject', 'id')
752
+ assert_equal c1.id, JSON(response.body).dig(2, 'subject', 'id')
753
+ assert_equal c2.id, JSON(response.body).dig(3, 'subject', 'id')
754
+ assert_equal c2.id, JSON(response.body).dig(4, 'subject', 'id')
755
+ end
756
+ end
757
+ end
758
+
699
759
  end
@@ -9,7 +9,11 @@ module AccountACL
9
9
  end
10
10
 
11
11
  def includes
12
- [ "photos", "subject", "property" ]
12
+ {
13
+ photos: true,
14
+ subject: [ 'landlord' ],
15
+ property: true
16
+ }
13
17
  end
14
18
 
15
19
  end
@@ -0,0 +1,7 @@
1
+ module CameraACL
2
+
3
+ def attributes
4
+ [ :make ]
5
+ end
6
+
7
+ end
@@ -0,0 +1,13 @@
1
+ module PhotoACL
2
+
3
+ def attributes
4
+ [
5
+ :format
6
+ ]
7
+ end
8
+
9
+ def nested
10
+ [ :account, :camera ]
11
+ end
12
+
13
+ end
@@ -1,5 +1,6 @@
1
1
  module PropertyACL
2
2
 
3
+ # Attributes allowed to be updated
3
4
  def attributes
4
5
  [ :name,
5
6
  :aliases,
@@ -12,16 +13,21 @@ module PropertyACL
12
13
  ]
13
14
  end
14
15
 
16
+ # Orderings allowed
15
17
  def orders
16
18
  ["id", "name", "aliases", "description", "constructed", "size", "created_at", "active"]
17
19
  end
18
20
 
21
+ # Sub resources allowed to be included in the response
19
22
  def includes
20
23
  [ :photos, :landlord, :english_name, :document ]
21
24
  end
22
25
 
26
+ # Sub resourced allowed to be set during create / update / delete if a user is
27
+ # allowed to ....
28
+ # only add to and from the relation, can also create or update the subresource
23
29
  def nested
24
- [ :photos ]
30
+ [ :photos, :accounts ]
25
31
  end
26
32
 
27
33
  end
@@ -2,6 +2,18 @@ class ApplicationController < ActionController::Base
2
2
  include StandardAPI::Controller
3
3
  include StandardAPI::AccessControlList
4
4
  prepend_view_path File.join(File.dirname(__FILE__), 'views')
5
+
6
+ helper_method :serialize_attribute
7
+
8
+ def serialize_attribute(json, record, attribute, type)
9
+ value = if attribute == 'description' && params["magic"] === "true"
10
+ 'See it changed!'
11
+ else
12
+ record.send(attribute)
13
+ end
14
+
15
+ json.set! attribute, type == :binary ? value&.unpack1('H*') : value
16
+ end
5
17
 
6
18
  end
7
19
 
@@ -48,6 +60,8 @@ class ReferencesController < ApplicationController
48
60
  end
49
61
 
50
62
  class SessionsController < ApplicationController
63
+ def create
64
+ end
51
65
  end
52
66
 
53
67
  class UnlimitedController < ApplicationController
@@ -73,3 +87,6 @@ class DefaultLimitController < ApplicationController
73
87
  end
74
88
 
75
89
  end
90
+
91
+ class UuidModelController < ApplicationController
92
+ end
@@ -7,12 +7,16 @@ class Account < ActiveRecord::Base
7
7
  end
8
8
 
9
9
  class Photo < ActiveRecord::Base
10
- belongs_to :account, :counter_cache => true
10
+ belongs_to :account, counter_cache: true
11
11
  has_and_belongs_to_many :properties
12
+ has_one :camera
12
13
  end
13
14
 
14
15
  class Document < ActiveRecord::Base
15
16
  attr_accessor :file
17
+
18
+ enum level: { public: 0, secret: 1 }, _suffix: true
19
+ enum rating: { poor: 0, ok: 1, good: 2 }
16
20
  end
17
21
 
18
22
  class Pdf < Document
@@ -34,8 +38,36 @@ class Property < ActiveRecord::Base
34
38
  end
35
39
  end
36
40
 
41
+ class LSNType < ActiveRecord::Type::Value
42
+
43
+ def type
44
+ :lsn
45
+ end
46
+
47
+ def cast_value(value)
48
+ case value
49
+ when Integer
50
+ [value].pack('N')
51
+ else
52
+ value&.to_s&.b
53
+ end
54
+ end
55
+
56
+ def serialize(value)
57
+ PG::TextEncoder::Bytea.new.encode(value)
58
+ end
59
+
60
+ def deserialize(value)
61
+ return nil if value.nil?
62
+ PG::TextDecoder::Bytea.new.decode(value).unpack1('N')
63
+ end
64
+
65
+ end
66
+
37
67
  class Reference < ActiveRecord::Base
38
68
  belongs_to :subject, polymorphic: true
69
+
70
+ attribute :custom_binary, LSNType.new
39
71
  end
40
72
 
41
73
  class Document < ActiveRecord::Base
@@ -47,7 +79,16 @@ class Attachment < ActiveRecord::Base
47
79
  belongs_to :document
48
80
  end
49
81
 
50
- # = Migration
82
+ class Camera < ActiveRecord::Base
83
+ end
84
+
85
+ class UuidModel < ActiveRecord::Base
86
+ end
87
+
88
+ # = Create/recreate database and migration
89
+ task = ActiveRecord::Tasks::PostgreSQLDatabaseTasks.new(ActiveRecord::Base.connection_db_config)
90
+ task.drop
91
+ task.create
51
92
 
52
93
  class CreateModelTables < ActiveRecord::Migration[6.0]
53
94
 
@@ -93,6 +134,8 @@ class CreateModelTables < ActiveRecord::Migration[6.0]
93
134
  create_table "references", force: :cascade do |t|
94
135
  t.integer "subject_id"
95
136
  t.string "subject_type", limit: 255
137
+ t.binary "sha"
138
+ t.binary "custom_binary"
96
139
  t.string "key"
97
140
  t.string "value"
98
141
  end
@@ -100,6 +143,7 @@ class CreateModelTables < ActiveRecord::Migration[6.0]
100
143
  create_table "photos_properties", force: :cascade do |t|
101
144
  t.integer "photo_id"
102
145
  t.integer "property_id"
146
+ t.index ["photo_id", "property_id"], unique: true
103
147
  end
104
148
 
105
149
  create_table "landlords_properties", force: :cascade do |t|
@@ -108,14 +152,27 @@ class CreateModelTables < ActiveRecord::Migration[6.0]
108
152
  end
109
153
 
110
154
  create_table "documents", force: :cascade do |t|
155
+ t.integer 'level', limit: 2, null: false, default: 0
156
+ t.integer 'rating', limit: 2
111
157
  t.string 'type'
112
158
  end
113
159
 
160
+ create_table "cameras", force: :cascade do |t|
161
+ t.integer 'photo_id'
162
+ t.string 'make'
163
+ end
164
+
114
165
  create_table "attachments", force: :cascade do |t|
115
166
  t.string 'record_type'
116
167
  t.integer 'record_id'
117
168
  t.integer 'document_id'
118
169
  end
170
+
171
+ create_table "uuid_models", id: :uuid, force: :cascade do |t|
172
+ t.string 'title', default: 'recruit'
173
+ t.string 'name', default: -> { 'round(random() * 1000)' }
174
+ end
175
+
119
176
  end
120
177
 
121
178
  end
@@ -48,4 +48,7 @@ FactoryBot.define do
48
48
  end
49
49
  end
50
50
 
51
+ factory :camera do
52
+ make { ['Sony', 'Nokia', 'Canon', 'Leica'].sample }
53
+ end
51
54
  end
@@ -0,0 +1 @@
1
+ json.set! 'includes', includes
@@ -0,0 +1,3 @@
1
+ json.object! do
2
+ json.set! 'includes', includes
3
+ end
@@ -18,7 +18,7 @@ class TestApplication < Rails::Application
18
18
  config.cache_classes = true
19
19
  config.action_controller.perform_caching = true
20
20
  config.cache_store = :memory_store, { size: 8.megabytes }
21
- config.action_dispatch.show_exceptions = false
21
+ config.action_dispatch.show_exceptions = :none
22
22
 
23
23
  # if defined?(FactoryBotRails)
24
24
  # config.factory_bot.definition_file_paths += [ '../factories' ]
@@ -28,6 +28,15 @@ end
28
28
  # Test Application initialization
29
29
  TestApplication.initialize!
30
30
 
31
+ # Make sure to test the right view files
32
+ ActionView::Template.unregister_template_handler :streamer, :jbuilder
33
+ case ENV["TSENCODER"]
34
+ when "turbostreamer"
35
+ ActionView::Template.register_template_handler :streamer, TurboStreamer::Handler
36
+ else
37
+ ActionView::Template.register_template_handler :jbuilder, JbuilderHandler
38
+ end
39
+
31
40
  # Test Application Models
32
41
  require 'standard_api/test_app/models'
33
42
 
@@ -44,6 +53,9 @@ Rails.application.routes.draw do
44
53
  end
45
54
 
46
55
  standard_resource :account
56
+ standard_resources :accounts, only: :index
57
+ # standard_resources :photos, only: [ :index, :show ]
58
+
47
59
  end
48
60
 
49
61
  # Test Application Helpers
@@ -64,6 +64,15 @@ class ActiveSupport::TestCase
64
64
 
65
65
  # = Helper Methods
66
66
 
67
+ def debug
68
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
69
+ $debugging = true
70
+ yield
71
+ ensure
72
+ ActiveRecord::Base.logger = nil
73
+ $debugging = false
74
+ end
75
+
67
76
  def controller_path
68
77
  if defined?(@controller)
69
78
  @controller.controller_path
@@ -76,16 +85,58 @@ class ActiveSupport::TestCase
76
85
  { :controller => controller_path, :action => action }.merge(options)
77
86
  end
78
87
 
79
- def assert_sql(sql, &block)
80
- queries = []
81
- callback = -> (*, payload) do
82
- queries << payload[:sql]
88
+ def assert_sql(*expected)
89
+ return_value = nil
90
+
91
+ queries_ran = if block_given?
92
+ queries_ran = SQLLogger.log.size
93
+ return_value = yield if block_given?
94
+ SQLLogger.log[queries_ran...]
95
+ else
96
+ [expected.pop]
83
97
  end
84
98
 
85
- ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
99
+ failed_patterns = []
100
+ expected.each do |pattern|
101
+ failed_patterns << pattern unless queries_ran.any?{ |sql| sql_equal(pattern, sql) }
102
+ end
103
+
104
+ assert failed_patterns.empty?, <<~MSG
105
+ Query pattern(s) not found:
106
+ - #{failed_patterns.map{|l| l.gsub(/\n\s*/, " ")}.join('\n - ')}
107
+ Queries Ran (queries_ran.size):
108
+ - #{queries_ran.map{|l| l.gsub(/\n\s*/, "\n ")}.join("\n - ")}
109
+ MSG
110
+
111
+ return_value
112
+ end
86
113
 
87
- assert_not_nil queries.map { |x| x.strip.gsub(/\s+/, ' ') }.
88
- find { |x| x == sql.strip.gsub(/\s+/, ' ') }
114
+ def assert_no_sql(*not_expected)
115
+ return_value = nil
116
+ queries_ran = block_given? ? SQLLogger.log.size : 0
117
+ return_value = yield if block_given?
118
+ ensure
119
+ failed_patterns = []
120
+ queries_ran = SQLLogger.log[queries_ran...]
121
+ not_expected.each do |pattern|
122
+ failed_patterns << pattern if queries_ran.any?{ |sql| sql_equal(pattern, sql) }
123
+ end
124
+ assert failed_patterns.empty?, <<~MSG
125
+ Unexpected Query pattern(s) found:
126
+ - #{failed_patterns.map(&:inspect).join('\n - ')}
127
+ Queries Ran (queries_ran.size):
128
+ - #{queries_ran.map{|l| l.gsub(/\n\s*/, "\n ")}.join("\n - ")}
129
+ MSG
130
+
131
+ return_value
132
+ end
133
+ def sql_equal(expected, sql)
134
+ sql = sql.strip.gsub(/"(\w+)"/, '\1').gsub(/\(\s+/, '(').gsub(/\s+\)/, ')').gsub(/\s+/, ' ')
135
+ if expected.is_a?(String)
136
+ expected = Regexp.new(Regexp.escape(expected.strip.gsub(/"(\w+)"/, '\1').gsub(/\(\s+/, '(').gsub(/\s+\)/, ')').gsub(/\s+/, ' ')), Regexp::IGNORECASE)
137
+ end
138
+
139
+ expected.match(sql)
89
140
  end
90
141
 
91
142
  def assert_rendered(options = {}, message = nil)
@@ -225,6 +276,48 @@ class ActiveSupport::TestCase
225
276
  end
226
277
  end
227
278
 
279
+ class SQLLogger
280
+ class << self
281
+ attr_accessor :ignored_sql, :log, :log_all
282
+ def clear_log; self.log = []; self.log_all = []; end
283
+ end
284
+
285
+ self.clear_log
286
+
287
+ self.ignored_sql = [/^PRAGMA/i, /^SELECT currval/i, /^SELECT CAST/i, /^SELECT @@IDENTITY/i, /^SELECT @@ROWCOUNT/i, /^SAVEPOINT/i, /^ROLLBACK TO SAVEPOINT/i, /^RELEASE SAVEPOINT/i, /^SHOW max_identifier_length/i, /^BEGIN/i, /^COMMIT/i]
288
+
289
+ # FIXME: this needs to be refactored so specific database can add their own
290
+ # ignored SQL, or better yet, use a different notification for the queries
291
+ # instead examining the SQL content.
292
+ oracle_ignored = [/^select .*nextval/i, /^SAVEPOINT/, /^ROLLBACK TO/, /^\s*select .* from all_triggers/im, /^\s*select .* from all_constraints/im, /^\s*select .* from all_tab_cols/im]
293
+ mysql_ignored = [/^SHOW FULL TABLES/i, /^SHOW FULL FIELDS/, /^SHOW CREATE TABLE /i, /^SHOW VARIABLES /, /^\s*SELECT (?:column_name|table_name)\b.*\bFROM information_schema\.(?:key_column_usage|tables)\b/im]
294
+ postgresql_ignored = [/^\s*select\b.*\bfrom\b.*pg_namespace\b/im, /^\s*select tablename\b.*from pg_tables\b/im, /^\s*select\b.*\battname\b.*\bfrom\b.*\bpg_attribute\b/im, /^SHOW search_path/i]
295
+ sqlite3_ignored = [/^\s*SELECT name\b.*\bFROM sqlite_master/im, /^\s*SELECT sql\b.*\bFROM sqlite_master/im]
296
+
297
+ [oracle_ignored, mysql_ignored, postgresql_ignored, sqlite3_ignored].each do |db_ignored_sql|
298
+ ignored_sql.concat db_ignored_sql
299
+ end
300
+
301
+ attr_reader :ignore
302
+
303
+ def initialize(ignore = Regexp.union(self.class.ignored_sql))
304
+ @ignore = ignore
305
+ end
306
+
307
+ def call(name, start, finish, message_id, values)
308
+ sql = values[:sql]
309
+
310
+ # FIXME: this seems bad. we should probably have a better way to indicate
311
+ # the query was cached
312
+ return if 'CACHE' == values[:name]
313
+
314
+ self.class.log_all << sql
315
+ # puts sql
316
+ self.class.log << sql unless ignore =~ sql
317
+ end
318
+ end
319
+ ActiveSupport::Notifications.subscribe('sql.active_record', SQLLogger.new)
320
+
228
321
  end
229
322
 
230
323
  class ActionController::TestCase