standardapi 6.1.0 → 7.1.1

Sign up to get free protection for your applications and to get access to all the features.
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