wor-simple_crud 0.5.0 → 0.7.0

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/Gemfile +5 -1
  4. data/README.md +104 -8
  5. data/lib/simple_crud/action_context.rb +45 -10
  6. data/lib/simple_crud/create_context.rb +2 -1
  7. data/lib/simple_crud/destroy_context.rb +2 -1
  8. data/lib/simple_crud/index_context.rb +30 -12
  9. data/lib/simple_crud/pagination/wor_paginate_adapter.rb +7 -1
  10. data/lib/simple_crud/persistence_context.rb +33 -6
  11. data/lib/simple_crud/relation_source.rb +107 -0
  12. data/lib/simple_crud/rspec/config.rb +6 -1
  13. data/lib/simple_crud/rspec/helpers/authentication.rb +2 -1
  14. data/lib/simple_crud/rspec/helpers/controller_options.rb +2 -2
  15. data/lib/simple_crud/rspec/helpers/requests.rb +5 -2
  16. data/lib/simple_crud/rspec.rb +2 -0
  17. data/lib/simple_crud/serializer.rb +14 -0
  18. data/lib/simple_crud/show_context.rb +3 -1
  19. data/lib/simple_crud/simple_crud_controller.rb +29 -6
  20. data/lib/simple_crud/update_context.rb +2 -1
  21. data/lib/simple_crud/version.rb +1 -1
  22. data/lib/spec/matchers/have_been_serialized_with.rb +19 -2
  23. data/lib/spec/shared_examples/simple_crud_for_create.rb +6 -6
  24. data/lib/spec/shared_examples/simple_crud_for_create_with_block.rb +2 -2
  25. data/lib/spec/shared_examples/simple_crud_for_create_with_build.rb +1 -1
  26. data/lib/spec/shared_examples/simple_crud_for_destroy.rb +8 -8
  27. data/lib/spec/shared_examples/simple_crud_for_destroy_with_block.rb +1 -1
  28. data/lib/spec/shared_examples/simple_crud_for_destroy_with_finder.rb +3 -3
  29. data/lib/spec/shared_examples/simple_crud_for_edit.rb +2 -2
  30. data/lib/spec/shared_examples/simple_crud_for_index.rb +3 -3
  31. data/lib/spec/shared_examples/simple_crud_for_index_with_block.rb +1 -1
  32. data/lib/spec/shared_examples/simple_crud_for_index_with_scope.rb +1 -1
  33. data/lib/spec/shared_examples/simple_crud_for_nested_resource.rb +21 -0
  34. data/lib/spec/shared_examples/simple_crud_for_new.rb +1 -1
  35. data/lib/spec/shared_examples/simple_crud_for_new_with_block.rb +1 -1
  36. data/lib/spec/shared_examples/simple_crud_for_new_with_build.rb +1 -1
  37. data/lib/spec/shared_examples/simple_crud_for_owned_resource.rb +35 -0
  38. data/lib/spec/shared_examples/simple_crud_for_show.rb +2 -2
  39. data/lib/spec/shared_examples/simple_crud_for_show_with_block.rb +1 -1
  40. data/lib/spec/shared_examples/simple_crud_for_show_with_finder.rb +1 -1
  41. data/lib/spec/shared_examples/simple_crud_for_update.rb +7 -7
  42. data/lib/spec/shared_examples/simple_crud_for_update_with_finder.rb +1 -1
  43. data/lib/spec/shared_examples/simple_crud_not_found_with_finder.rb +1 -1
  44. data/spec/blueprint/dummy_models_controller_spec.rb +25 -0
  45. data/spec/blueprint_unpaginated/dummy_models_controller_spec.rb +9 -0
  46. data/spec/dummy/app/blueprints/dummy_model_blueprint.rb +10 -0
  47. data/spec/dummy/app/controllers/blueprint/dummy_models_controller.rb +21 -0
  48. data/spec/dummy/app/controllers/blueprint_unpaginated/dummy_models_controller.rb +10 -0
  49. data/spec/dummy/app/controllers/html_notice/dummy_models_controller.rb +11 -0
  50. data/spec/dummy/app/controllers/owned/dummy_models_controller.rb +21 -0
  51. data/spec/dummy/app/controllers/parented/dummy_models_controller.rb +23 -0
  52. data/spec/dummy/app/controllers/scoped_symbol/dummy_models_controller.rb +11 -0
  53. data/spec/dummy/app/models/dummy_model.rb +4 -0
  54. data/spec/dummy/app/serializers/dummy_model_plain_serializer.rb +8 -0
  55. data/spec/dummy/app/views/html_notice/dummy_models/edit.html.erb +1 -0
  56. data/spec/dummy/config/routes.rb +24 -0
  57. data/spec/dummy_model_blueprint_spec.rb +16 -0
  58. data/spec/html_notice/dummy_models_controller_spec.rb +38 -0
  59. data/spec/owned/dummy_models_controller_spec.rb +12 -0
  60. data/spec/parented/dummy_models_controller_spec.rb +29 -0
  61. data/spec/scoped_symbol/dummy_models_controller_spec.rb +7 -0
  62. data/spec/simple_crud/action_context_spec.rb +34 -0
  63. data/spec/simple_crud/index_context_spec.rb +49 -0
  64. data/spec/simple_crud/relation_source_spec.rb +125 -0
  65. data/spec/simple_crud/rspec_config_spec.rb +13 -0
  66. data/spec/simple_crud/serializer_spec.rb +49 -0
  67. data/spec/simple_crud/show_context_spec.rb +5 -0
  68. data/spec/simple_crud_controller_spec.rb +15 -0
  69. metadata +25 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5b50548416811b034a75f72c96d4e94c41cdb9883d7112cdf720ad3febaeb11
4
- data.tar.gz: 99ae1562bae38c2fa8ff1d697859f8643e2b818aaeb66f2e14220171fa9f28dc
3
+ metadata.gz: 32e4043eb57f025e3a834a364be09cae1e35fa401bbf95bac88351272297d4f9
4
+ data.tar.gz: bb88b34edd232839b1b760a2bcb834bd25f8a244d3ad02c4a91097f14d85b377
5
5
  SHA512:
6
- metadata.gz: ef7dd6acb64f882534e9951a9adf2d8deab27f2264451cb1dee2613c78e4842e38d10a638a2ed9ec623984d0d571aee558e2a3626f7bf8f016eca65dc3174ae3
7
- data.tar.gz: 43a9b8fb46febb90d101814a33d48049ba0a5c2df3a3b14568c5423816ebd55325966fabe8673320ef3ed89b1a7bfe0ac10f4940c16b0e412649b100dd08e051
6
+ metadata.gz: e173f6052e2c1674896a08067369068d2ab9f3c50c082f4a52bf8f4d7db3a9e27f548df1a55fd12bc22e9cc4a360095033d4693ecd5b6957fae7c6b09bd24224
7
+ data.tar.gz: a1ffc6e33056614ff6806f2b15716d21e749777de66619f23ef0025b4e5561c60e89bbb2d0df27abc3817f248cf21aa8a6ed7cbcdcc0dad67a561411084ba2f0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  ## Change log
2
2
 
3
+ ### V0.7.0
4
+
5
+ Serializer support:
6
+ * `serializer:` accepts a plain class exposing a `.render` class method, with no base class or gem dependency. simple_crud calls `serializer.render(record, **options)`, so a `def self.render(record, key: default)` signature works untouched. It checks this after Blueprinter's `render_as_hash` and before the ActiveModelSerializers fallback, leaving existing Blueprinter and AMS apps unaffected.
7
+
8
+ New options:
9
+ * `owned_by:` (Symbol): names an association on `Config.user_method` (`current_user.posts` for `owned_by: :posts`) and supplies the default `finder:`/`build:`/index `scope:` from it, so an owned resource stops spelling out `finder: ->(params) { current_user.posts.find(params[:id]) }` and `build: -> { current_user.posts.build }` on every action. An explicit `finder:`/`build:`/`scope:` overrides the default for that option alone. On `:index`, `owned_by:` takes precedence over the Pundit `policy_scope`, and an explicit `scope:` overrides both. With no current user, an owned `:index` returns no records and an owned `:show`/`:update`/`:destroy` returns `not_found`.
10
+ * `parent:` names a nested route's parent and derives the same defaults from `parent.public_send(parent_association)`. A Symbol resolves a controller method first, then an `@ivar`, so a conventional `before_action` works unchanged; a `Proc` runs in controller context. `parent_association:` defaults to the controller model's plural (`ExamsController` gives `@category.exams`). An explicit `finder:`/`build:`/`scope:` overrides the default, and a nil parent fails closed (404 on find/build, empty index). `parent:` and `owned_by:` both resolve a relation from an owner, so setting both raises `ArgumentError`.
11
+
12
+ Behavior changes:
13
+ * `scope:` on `:index` accepts a `Symbol` naming a class method on the model, called as `klass.send(scope, user, params)`. Query composition can live on the model instead of inline in the controller.
14
+ * `scope:` lambdas run with the controller as `self`, so a scope can read `@ivars` and route-parent state. Arity 0 receives no arguments (it previously errored by being passed two), arity 1 the user, anything else the user and params.
15
+ * `simple_crud_for` accepts an Array of actions to declare several at once: `simple_crud_for %i[show create update destroy], owned_by: :tasks, serializer: TaskSerializer`.
16
+ * `notice:`/`alert:` on HTML `:create`/`:update`/`:destroy`: set a flash message on the success redirect (`notice`) or the failure re-render (`alert`).
17
+
18
+ ### V0.6.0
19
+
20
+ Serializer support:
21
+ * Blueprinter: `serializer:` now accepts a [Blueprinter](https://github.com/blueprinter/blueprinter) blueprint on every action, which simple_crud renders with `render_as_hash(record, options)`, as well as an ActiveModelSerializers serializer. The shared examples' default `serializer_class` uses `"#{Model}Blueprint"` when no `"#{Model}Serializer"` constant exists, and `have_been_serialized_with` reads Blueprinter fields.
22
+ * `serializer_options:` on every action: a lambda that runs through `instance_exec`. simple_crud forwards its result to the serializer (`render_as_hash(record, options)` for Blueprinter, `new(record, options)` for AMS). Arity 0 runs with no arguments; arity ≥ 1 gets the record. On `:index` it runs once with no record, so arity-1 lambdas get `nil`. Paginated Blueprinter indexes need wor-paginate ≥ 0.5.
23
+
24
+ New options:
25
+ * `status:` on `:create`/`:update`/`:destroy` overrides the success status (`:created`/`:ok`/`:ok` by default). `status: :no_content` returns an empty body.
26
+ * `after_persist:` on `:create`/`:update`/`:destroy`: a `(record, saved)` lambda that runs after the write and before rendering. Use it for side effects like cache invalidation. It still runs when you pass a render block.
27
+
28
+ Behavior changes:
29
+ * `create`/`update` responses now honor `serializer:`. Before, they returned the raw record. AMS apps that never passed `serializer:` see no change.
30
+ * Cached `:show` now honors `serializer:`. Before, it returned `record.as_json`.
31
+ * Unpaginated `:index` now serializes through the same path as the other actions, so Blueprinter works there. Before, it passed `each_serializer` to `render`.
32
+
33
+ Fixes:
34
+ * `finder:` lambdas now run through `instance_exec` on the controller, so `current_user` and other controller methods are available, matching the README.
35
+ * `:index` resolves a private `current_user` method.
36
+ * Shared examples no longer inject `params[:format]`, which collided with model attributes named `format`.
37
+ * Shared-example `owner_foreign_key` honors custom `foreign_key:` declarations.
38
+ * The base `index` example compares ids with `match_array`; create/update/destroy examples honor a custom `status:`.
39
+
3
40
  ### V0.5.0
4
41
 
5
42
  New options:
data/Gemfile CHANGED
@@ -17,6 +17,7 @@ group :development, :test do
17
17
  gem 'action_policy', '~> 0.7.6', require: false
18
18
  gem 'active_model_serializers', '~> 0.10.16'
19
19
  gem 'appraisal', '~> 2.5'
20
+ gem 'blueprinter', '~> 1.3'
20
21
  gem 'byebug', '~> 13.0'
21
22
  gem 'cancancan', '~> 3.6', require: false
22
23
  gem 'database_cleaner-active_record', '~> 2.2', require: 'database_cleaner/active_record'
@@ -24,6 +25,9 @@ group :development, :test do
24
25
  gem 'devise-jwt', '~> 0.13'
25
26
  gem 'factory_bot_rails', '~> 6.5'
26
27
  gem 'faker', '~> 3.8'
28
+ # json 3 dropped the `quirks_mode` keyword that ActionController::TestCase
29
+ # (and thus rails-controller-testing) still passes on Rails <= 8.0.
30
+ gem 'json', '< 3'
27
31
  gem 'kaminari', '~> 1.2', require: false
28
32
  gem 'overcommit', '~> 0.72'
29
33
  gem 'pagy', '~> 43.0', require: false
@@ -39,5 +43,5 @@ group :development, :test do
39
43
  # required range differs across supported versions.
40
44
  gem 'webmock', '~> 3.26'
41
45
  gem 'will_paginate', '~> 4.0', require: false
42
- gem 'wor-paginate', '~> 0.4'
46
+ gem 'wor-paginate', '~> 0.5'
43
47
  end
data/README.md CHANGED
@@ -18,6 +18,8 @@ SimpleCrud
18
18
  - [Serializer](#serializer)
19
19
  - [HTML](#html)
20
20
  - [Finder](#finder)
21
+ - [Owned by](#owned-by)
22
+ - [Parent](#parent)
21
23
  - [Cache](#cache)
22
24
  - [Controller-level defaults](#controller-level-defaults)
23
25
  - [Shared examples](#shared-examples)
@@ -94,6 +96,12 @@ simple_crud_for :new
94
96
  simple_crud_for :edit
95
97
  ```
96
98
 
99
+ Declare several actions with the same options at once by passing an Array:
100
+ ```ruby
101
+ simple_crud_for %i[show create update destroy], owned_by: :tasks, serializer: TaskSerializer
102
+ simple_crud_for :destroy, status: :no_content # per-action override on top of the batch
103
+ ```
104
+
97
105
  Each method supports different options, as in:
98
106
  ```
99
107
  simple_crud_for :index, paginate: false, authorize: false, serializer: CustomSerializer
@@ -103,13 +111,18 @@ simple_crud_for :index, paginate: false, authorize: false, serializer: CustomSer
103
111
  - Authorize: whether it should check authorization via the configured authorization adapter (Pundit by default)
104
112
  - Authenticate: whether the generated action calls `authenticate_user!`. `true` (default) or `false`
105
113
  - Authenticate_headers: whether shared examples set auth headers and run the unauthorized test. Defaults to `authenticate:`. Set independently when a base-controller `before_action` handles auth and the lambda should skip `authenticate_user!`
106
- - Serializer: specify a particular serializer you should use
114
+ - Serializer: specify a particular serializer you should use. Accepts an ActiveModelSerializers serializer or a Blueprinter blueprint (see [Serializer](#serializer))
115
+ - Serializer_options: a `Proc`/`lambda` that runs with the controller as `self`. Its result goes to the serializer: `render_as_hash(record, options)` for Blueprinter, `new(record, options)` for ActiveModelSerializers. Arity 0 runs without arguments; arity ≥ 1 receives the record. On `:index` it runs once with no record
116
+ - Status: only valid for `:create`, `:update` and `:destroy`. Overrides the success status (`:created`, `:ok` and `:ok` by default). `status: :no_content` responds with an empty body
117
+ - After_persist: only valid for `:create`, `:update` and `:destroy`. A `Proc`/`lambda` `->(record, saved) { ... }` that runs with the controller as `self`, after the write and before rendering. Use it for side effects like cache invalidation. It still runs when you pass a render block
107
118
  - Html: renders the action's ERB template instead of JSON (valid for `:index`, `:show`, `:new`, `:edit`, `:create`, `:update` and `:destroy`). Only meaningful in controllers that render templates
108
- - Scope: only valid for `:index`. A `Proc`/`lambda` taking `current_user` (plus the controller's `params` if it takes a second argument) that returns the relation to list, overriding the default `policy_scope`. The user is resolved via `SimpleCrud::Config.user_method` (`:current_user` by default; set it to e.g. `:current_admin`)
119
+ - Scope: only valid for `:index`. A `Proc`/`lambda` that runs with the controller as `self` (so `@ivars` and route-parent state are available) and returns the relation to list, overriding the default `policy_scope`. It takes `current_user` (plus the controller's `params` when it takes a second argument); an arity-0 lambda gets no arguments. The user is resolved via `SimpleCrud::Config.user_method` (`:current_user` by default; set it to e.g. `:current_admin`)
109
120
  - Finder: only valid for `:show`, `:update`, `:destroy` and `:edit`. A `Proc`/`lambda` (invoked with the controller's params) or a `Symbol` naming a class method on the model, used to look up the record instead of `klass.find(params[:id])`.
110
121
  - Build: only valid for `:new` and `:create`. A `Proc`/`lambda` that builds the record (invoked with the controller as `self`, so `current_user`, `params` and any instance variables are available), for building nested or owner-scoped records like `current_user.projects.build`. `:create` then assigns the permitted params to the built record before saving
111
122
  - Raise_on_invalid: only valid for `:create` and `:update`. Keeps the strict `create!`/`update!` semantics (raising on invalid input) instead of returning `422` with the validation errors
112
123
  - Redirect: only valid for HTML-mode `:create`, `:update` and `:destroy`. A `Proc`/`lambda` (called with the record) or a literal path used as the success redirect target. Defaults to the record (`:create`/`:update`) or the model's collection path (`:destroy`)
124
+ - Notice: only valid for HTML-mode `:create`, `:update` and `:destroy`. A flash message set on the success redirect. No-op when the controller has no `flash`
125
+ - Alert: only valid for HTML-mode `:create`, `:update` and `:destroy`. A `flash.now` message set before re-rendering on failure. No-op when the controller has no `flash`
113
126
 
114
127
  #### Controller-level defaults
115
128
 
@@ -200,12 +213,19 @@ end
200
213
 
201
214
  ```
202
215
 
203
- When `authorize: true`, the `:index` action paginates the Pundit `policy_scope` of the model (falling back to the full relation when no `Scope` is defined) instead of `klass.all`, so "only my records" scoping works out of the box. Override the scope per action with the `scope:` option, a callable that receives the user resolved via `SimpleCrud::Config.user_method` (`nil` when there is none, and `params` too when it takes a second argument):
216
+ When `authorize: true`, the `:index` action paginates the Pundit `policy_scope` of the model (falling back to the full relation when no `Scope` is defined) instead of `klass.all`, so "only my records" scoping works out of the box. Override the scope per action with `scope:`: a callable that receives the user resolved via `SimpleCrud::Config.user_method` (`nil` when there is none, and `params` too when it takes a second argument), or a `Symbol` naming a class method on the model so query composition lives on the model:
204
217
 
205
218
  ```ruby
206
219
  SimpleCrud.configure { |c| c.user_method = :current_admin } # non-Devise naming conventions
207
220
  simple_crud_for :index, scope: ->(user) { Model.visible_to(user) }
208
221
  simple_crud_for :index, scope: ->(user, params) { Model.where(status: params[:status]).visible_to(user) }
222
+ simple_crud_for :index, scope: :visible_to # calls Model.visible_to(user, params)
223
+ ```
224
+
225
+ A `scope:` lambda runs with the controller as `self`, so it can reach `@ivars` and route-parent context. Arity 0 receives no arguments, arity 1 the user, anything else the user and params:
226
+
227
+ ```ruby
228
+ simple_crud_for :index, scope: -> { @category.exams.includes(:questions).order(:title) }
209
229
  ```
210
230
 
211
231
  Prefer CanCanCan or Action Policy instead? Both have adapters ready to go:
@@ -269,12 +289,29 @@ simple_crud_defaults authenticate: false, authenticate_headers: true
269
289
  ```
270
290
 
271
291
  #### Serializer
272
- The name of the serializer, by default, is the name of the model followed by Serializer, as is the standard for [ActiveModelSerializers](https://github.com/rails-api/active_model_serializers). It's possible to just pass a custom serializer class though. As for the serializer itself, it's a standard serializer, with the gotcha that you need to include `:id` for the SimpleCrud examples to work.
292
+ `serializer:` picks the serializer for JSON responses on every action. Pass an [ActiveModelSerializers](https://github.com/rails-api/active_model_serializers) serializer class, which simple_crud builds per record with `serializer.new(record, serializer_options)`; a [Blueprinter](https://github.com/blueprinter/blueprinter) blueprint class, rendered with `blueprint.render_as_hash(record, serializer_options)`; or any plain class of your own that responds to `.render`, called as `serializer.render(record, **serializer_options)`, with no base class or extra gem required.
293
+
294
+ Without `serializer:`, the response uses the record's `as_json`. The shared examples expect an `:id` attribute. Paginated `:index` with Blueprinter needs wor-paginate ≥ 0.5.
273
295
 
274
296
  ```ruby
275
297
  class AuthorSerializer < ActiveModel::Serializer
276
- attributes :email, :first_name, :last_name, :institution, :role, :id
298
+ attributes :email, :first_name, :last_name, :id
277
299
  end
300
+
301
+ class AuthorBlueprint < Blueprinter::Base
302
+ identifier :id
303
+ fields :email, :first_name, :last_name
304
+ end
305
+
306
+ # A plain class works too, with no dependency on either gem above:
307
+ class AuthorPlainSerializer
308
+ def self.render(author, **)
309
+ { id: author.id, email: author.email, first_name: author.first_name, last_name: author.last_name }
310
+ end
311
+ end
312
+
313
+ simple_crud_for :show, serializer: AuthorBlueprint
314
+ simple_crud_for :index, serializer: AuthorBlueprint, serializer_options: -> { { current_user: current_user } }
278
315
  ```
279
316
 
280
317
  #### HTML
@@ -293,11 +330,13 @@ simple_crud_for :index, html: true
293
330
  simple_crud_for :show, html: true
294
331
  simple_crud_for :new, html: true
295
332
  simple_crud_for :edit, html: true
296
- simple_crud_for :create, html: true
297
- simple_crud_for :update, html: true
298
- simple_crud_for :destroy, html: true
333
+ simple_crud_for :create, html: true, notice: 'Created!', alert: 'Could not create.'
334
+ simple_crud_for :update, html: true, redirect: ->(record) { record_path(record) }, notice: 'Saved!'
335
+ simple_crud_for :destroy, html: true, notice: 'Removed.'
299
336
  ```
300
337
 
338
+ `notice:` sets a flash message on the success redirect; `alert:` sets a `flash.now` message before the failure re-render. Both are skipped when the controller has no `flash` (e.g. `ActionController::API`).
339
+
301
340
  Or pass a block that renders explicitly, overriding the auto-render. The block receives the records for `:index`, the record for `:show`/`:new`, or the record plus a saved flag for `:create`/`:update`/`:destroy`. Blocks do not change the `html:` default; pass it explicitly so the shared examples know which request format to use:
302
341
 
303
342
  ```ruby
@@ -344,6 +383,63 @@ simple_crud_for :destroy, finder: ->(params) { current_user.models.find(params[:
344
383
 
345
384
  When omitted it defaults to `klass.find(params[:id])`, and `not_found` is still returned whenever the finder finds no record.
346
385
 
386
+ #### Owned by
387
+ `owned_by:` covers a resource scoped to the current user's own records. It names an association on `Config.user_method` (`current_user` by default) and derives `finder:`, `build:` and the index `scope:` from it:
388
+
389
+ ```ruby
390
+ class PostsController < ApplicationController
391
+ simple_crud_defaults owned_by: :posts
392
+ simple_crud_for :index
393
+ simple_crud_for :show
394
+ simple_crud_for :create
395
+ simple_crud_for :update
396
+ simple_crud_for :destroy
397
+ end
398
+ ```
399
+
400
+ This is equivalent to writing, on every action:
401
+
402
+ ```ruby
403
+ simple_crud_for :index, scope: ->(user) { user.posts }
404
+ simple_crud_for :show, finder: ->(params) { current_user.posts.find(params[:id]) }
405
+ simple_crud_for :create, build: -> { current_user.posts.build }
406
+ simple_crud_for :update, finder: ->(params) { current_user.posts.find(params[:id]) }
407
+ simple_crud_for :destroy, finder: ->(params) { current_user.posts.find(params[:id]) }
408
+ ```
409
+
410
+ An explicit `finder:`, `build:` or `scope:` on an action overrides the `owned_by:` default for that option alone, so an owned `:show`/`:update`/`:destroy` can use a hand-written `:index` scope that needs extra filtering or eager loading. On `:index`, `owned_by:` takes precedence over the Pundit `policy_scope`; an explicit `scope:` overrides both. With no current user, an owned `:index` returns no records and an owned `:show`/`:update`/`:destroy` returns `not_found`, instead of falling back to the unscoped relation.
411
+
412
+ Keep the owner's foreign key out of strong params. If `owned_by: :posts` sets the owner through the built association, `params.permit(:user_id)` lets a request override it.
413
+
414
+ #### Parent
415
+ `parent:` covers a nested route. It derives the same `finder:`, `build:` and index `scope:` from an association on a parent resource instead of on the current user. Both options resolve an association on some owner, so setting them together raises `ArgumentError`.
416
+
417
+ `parent:` takes a `Symbol` that resolves a controller method first, then an `@ivar`. A `before_action` that sets `@category` works unchanged:
418
+
419
+ ```ruby
420
+ class ExamsController < ApplicationController
421
+ before_action :set_category
422
+
423
+ simple_crud_defaults parent: :category # reads the category method, else @category
424
+ simple_crud_for %i[index show new create], serializer: ExamSerializer, html: true
425
+
426
+ private
427
+
428
+ def set_category = @category = Category.find_by!(slug: params.expect(:category_slug))
429
+ end
430
+ ```
431
+
432
+ The association comes from the controller's model, so `ExamsController` gets `@category.exams`. Pass `parent_association:` when the name differs, or a `Proc` (run in controller context) for a parent that isn't an ivar or method:
433
+
434
+ ```ruby
435
+ simple_crud_for :show, parent: :category, parent_association: :published_exams
436
+ simple_crud_for :show, parent: -> { @listing.interview }
437
+ ```
438
+
439
+ As with `owned_by:`, an explicit `finder:`/`build:`/`scope:` overrides the derived default for that option, and a nil parent fails closed: `:show`/`:update`/`:destroy` return `not_found` and `:index` returns no records.
440
+
441
+ The same strong-params caveat applies: `:create` builds through the parent association and then assigns the permitted params, so never permit the parent's foreign key (for a `@category.exams` controller, don't permit `:category_id`).
442
+
347
443
  #### Cache
348
444
  Pass `cache: { key:, ttl: }` on `:show` or `:index` to skip the DB on cache hits.
349
445
 
@@ -50,14 +50,32 @@ module SimpleCrud
50
50
 
51
51
  def lookup_record
52
52
  finder = parameters[:finder]
53
- return klass.find(controller.params[:id]) if finder.nil?
53
+ return find_by_id if finder.nil?
54
54
  return klass.send(finder, controller.params) unless finder.respond_to?(:call)
55
55
 
56
- finder.call(controller.params)
56
+ controller.instance_exec(controller.params, &finder)
57
+ end
58
+
59
+ def find_by_id
60
+ finder_scope.find(controller.params[:id])
57
61
  end
58
62
 
59
63
  def build_record
60
- parameters[:build] ? controller.instance_exec(&parameters[:build]) : klass.new
64
+ return controller.instance_exec(&parameters[:build]) if parameters[:build]
65
+ return relation_source.relation!.build if relation_source.configured?
66
+
67
+ klass.new
68
+ end
69
+
70
+ # Falls back to klass when owned_by:/parent: is unset.
71
+ def finder_scope
72
+ return klass unless relation_source.configured?
73
+
74
+ relation_source.relation!
75
+ end
76
+
77
+ def relation_source
78
+ @relation_source ||= SimpleCrud::RelationSource.new(controller, klass, parameters)
61
79
  end
62
80
 
63
81
  def permitted_params
@@ -65,16 +83,33 @@ module SimpleCrud
65
83
  end
66
84
 
67
85
  def serialize_opts(key)
68
- { key => parameters[:serializer] }.compact
86
+ opts = { key => parameters[:serializer] }.compact
87
+ options = serializer_options
88
+ opts[:serializer_options] = options if options.present?
89
+ opts
90
+ end
91
+
92
+ def serializer_options(record = nil)
93
+ callable = parameters[:serializer_options]
94
+ return {} unless callable
95
+
96
+ callable.arity.zero? ? controller.instance_exec(&callable) : controller.instance_exec(record, &callable)
69
97
  end
70
98
 
71
99
  def render_record(record, template)
72
- if parameters[:html] || block
73
- controller.instance_variable_set(:@record, record)
74
- block ? controller.instance_exec(record, &block) : controller.render(template)
75
- else
76
- controller.render({ json: record }.merge(serialize_opts(:serializer)))
77
- end
100
+ return render_html_record(record, template) if parameters[:html] || block
101
+
102
+ render_json_record(record)
103
+ end
104
+
105
+ def render_html_record(record, template)
106
+ controller.instance_variable_set(:@record, record)
107
+ block ? controller.instance_exec(record, &block) : controller.render(template)
108
+ end
109
+
110
+ def render_json_record(record)
111
+ serializer = parameters[:serializer]
112
+ controller.render json: SimpleCrud::Serializer.render(serializer, record, serializer_options(record))
78
113
  end
79
114
 
80
115
  def render_cached(cache_opts, &fetch_block)
@@ -10,7 +10,8 @@ module SimpleCrud
10
10
  record.assign_attributes(attrs)
11
11
  maybe_authorize(record)
12
12
  persist = ->(bang:) { bang ? record.save! : record.save }
13
- options = { status: :created, failure_template: :new, redirect: parameters[:redirect] || record }
13
+ options = { status: parameters[:status] || :created, failure_template: :new,
14
+ redirect: parameters[:redirect] || record }
14
15
  persist_and_render(record, options, persist)
15
16
  end
16
17
  end
@@ -8,7 +8,8 @@ module SimpleCrud
8
8
  record = find_record
9
9
  maybe_authorize(record)
10
10
  persist = ->(bang:) { bang ? record.destroy! : record.destroy }
11
- options = { status: :ok, failure_template: :show, redirect: parameters[:redirect] || klass }
11
+ options = { status: parameters[:status] || :ok, failure_template: :show,
12
+ redirect: parameters[:redirect] || klass }
12
13
  persist_and_render(record, options, persist)
13
14
  end
14
15
  end
@@ -17,7 +17,14 @@ module SimpleCrud
17
17
  relation = index_relation
18
18
  records = index_records(relation, opts)
19
19
  controller.instance_variable_set(:@records, records)
20
- block ? controller.instance_exec(records, &block) : records.as_json
20
+ block ? controller.instance_exec(records, &block) : serialize_records(records)
21
+ end
22
+
23
+ def serialize_records(records)
24
+ serializer = parameters[:serializer]
25
+ return records.as_json if serializer.nil?
26
+
27
+ records.map { |record| SimpleCrud::Serializer.render(serializer, record, serializer_options) }
21
28
  end
22
29
 
23
30
  def render_index
@@ -28,7 +35,7 @@ module SimpleCrud
28
35
  elsif parameters[:paginate]
29
36
  SimpleCrud::Config.pagination_adapter.paginate(controller, relation, opts)
30
37
  else
31
- controller.render({ json: relation }.merge(opts))
38
+ controller.render json: serialize_records(relation)
32
39
  end
33
40
  end
34
41
 
@@ -39,20 +46,31 @@ module SimpleCrud
39
46
  end
40
47
 
41
48
  def index_relation
42
- if parameters[:scope]
43
- call_scope
44
- elsif parameters[:authorize]
45
- SimpleCrud::Config.authorization_adapter.policy_scope(controller, klass)
46
- else
47
- klass.all
48
- end
49
+ return call_scope if parameters[:scope]
50
+ return relation_source.relation || klass.none if relation_source.configured?
51
+
52
+ default_index_relation
53
+ end
54
+
55
+ def default_index_relation
56
+ return SimpleCrud::Config.authorization_adapter.policy_scope(controller, klass) if parameters[:authorize]
57
+
58
+ klass.all
49
59
  end
50
60
 
51
61
  def call_scope
52
- user_method = SimpleCrud::Config.user_method
53
- user = controller.respond_to?(user_method) ? controller.public_send(user_method) : nil
54
62
  scope = parameters[:scope]
55
- scope.arity == 1 ? scope.call(user) : scope.call(user, controller.params)
63
+ return klass.send(scope, relation_source.user, controller.params) unless scope.respond_to?(:call)
64
+
65
+ controller.instance_exec(*scope_arguments(scope), &scope)
66
+ end
67
+
68
+ def scope_arguments(scope)
69
+ case scope.arity
70
+ when 0 then []
71
+ when 1 then [relation_source.user]
72
+ else [relation_source.user, controller.params]
73
+ end
56
74
  end
57
75
 
58
76
  def index_records(relation, opts)
@@ -10,7 +10,13 @@ module SimpleCrud
10
10
  include Adapter
11
11
 
12
12
  def paginate(controller, relation, options)
13
- controller.send(:render_paginated, relation, options)
13
+ serializer = options[:each_serializer]
14
+ return controller.send(:render_paginated, relation, options) if serializer.nil?
15
+
16
+ serializer_options = options[:serializer_options] || {}
17
+ controller.send(:render_paginated, relation, options) do |record|
18
+ SimpleCrud::Serializer.render(serializer, record, serializer_options)
19
+ end
14
20
  end
15
21
 
16
22
  def paginated_records(controller, relation, options)
@@ -6,18 +6,35 @@ module SimpleCrud
6
6
 
7
7
  def persist_and_render(record, options, persist)
8
8
  saved = parameters[:raise_on_invalid] ? persist.call(bang: true) : persist.call(bang: false)
9
+ run_after_persist(record, saved)
9
10
  return render_persisted(record, saved, options) unless block || parameters[:html]
10
11
 
11
12
  controller.instance_variable_set(:@record, record)
12
13
  block ? controller.instance_exec(record, saved, &block) : render_html_redirect(record, saved, options)
13
14
  end
14
15
 
16
+ def run_after_persist(record, saved)
17
+ hook = parameters[:after_persist]
18
+ controller.instance_exec(record, saved, &hook) if hook
19
+ end
20
+
15
21
  def render_html_redirect(record, saved, options)
16
- if saved
17
- controller.redirect_to(redirect_target(record, options[:redirect]))
18
- else
19
- controller.render(options[:failure_template])
20
- end
22
+ return render_html_failure(options) unless saved
23
+
24
+ set_flash(:notice, parameters[:notice])
25
+ controller.redirect_to(redirect_target(record, options[:redirect]))
26
+ end
27
+
28
+ def render_html_failure(options)
29
+ set_flash(:alert, parameters[:alert], now: true)
30
+ controller.render(options[:failure_template])
31
+ end
32
+
33
+ def set_flash(key, message, now: false)
34
+ return if message.nil? || !controller.respond_to?(:flash)
35
+
36
+ target = now ? controller.flash.now : controller.flash
37
+ target[key] = message
21
38
  end
22
39
 
23
40
  def redirect_target(record, target)
@@ -25,9 +42,19 @@ module SimpleCrud
25
42
  end
26
43
 
27
44
  def render_persisted(record, saved, options)
28
- return controller.render(json: record, status: options[:status]) if saved
45
+ return render_success(record, options[:status]) if saved
29
46
 
30
47
  controller.render json: { errors: record.errors.full_messages }, status: 422
31
48
  end
49
+
50
+ def render_success(record, status)
51
+ return controller.head(status) if status == :no_content
52
+
53
+ controller.render json: payload(record), status: status
54
+ end
55
+
56
+ def payload(record)
57
+ SimpleCrud::Serializer.render(parameters[:serializer], record, serializer_options(record))
58
+ end
32
59
  end
33
60
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ # Resolves the owner shared by owned_by: and parent:.
5
+ class RelationSource
6
+ def initialize(controller, klass, parameters)
7
+ @controller = controller
8
+ @klass = klass
9
+ @parameters = parameters
10
+ end
11
+
12
+ def configured?
13
+ !owned_by.nil? || !parent.nil?
14
+ end
15
+
16
+ # nil when the owner can't be resolved.
17
+ def relation
18
+ return owner.relation if owned_by
19
+ return parent_source.relation if parent
20
+
21
+ nil
22
+ end
23
+
24
+ def relation!
25
+ relation || raise(ActiveRecord::RecordNotFound, "no #{description} to scope the record to")
26
+ end
27
+
28
+ def user
29
+ Source.user(controller)
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :controller, :klass, :parameters
35
+
36
+ def owned_by = parameters[:owned_by]
37
+
38
+ def parent = parameters[:parent]
39
+
40
+ def description
41
+ owned_by ? "owner for #{owned_by}" : "parent for #{parent}"
42
+ end
43
+
44
+ def owner
45
+ OwnerSource.new(controller, parameters[:owned_by])
46
+ end
47
+
48
+ def parent_source
49
+ ParentSource.new(controller, klass, parameters)
50
+ end
51
+
52
+ # Shared by both relation sources.
53
+ module Source
54
+ def self.user(controller)
55
+ user_method = SimpleCrud::Config.user_method
56
+ controller.respond_to?(user_method, true) ? controller.send(user_method) : nil
57
+ end
58
+ end
59
+
60
+ # nil without a user.
61
+ class OwnerSource
62
+ def initialize(controller, association)
63
+ @controller = controller
64
+ @association = association
65
+ end
66
+
67
+ def relation
68
+ Source.user(@controller)&.public_send(@association)
69
+ end
70
+ end
71
+
72
+ # A Symbol resolves method then @ivar.
73
+ class ParentSource
74
+ def initialize(controller, klass, parameters)
75
+ @controller = controller
76
+ @klass = klass
77
+ @parameters = parameters
78
+ end
79
+
80
+ def relation
81
+ resolved&.public_send(association)
82
+ end
83
+
84
+ private
85
+
86
+ def resolved
87
+ reference = @parameters[:parent]
88
+ return @controller.instance_exec(&reference) if reference.respond_to?(:call)
89
+
90
+ by_method_or_ivar(reference)
91
+ end
92
+
93
+ def by_method_or_ivar(reference)
94
+ return @controller.send(reference) if @controller.respond_to?(reference, true)
95
+
96
+ ivar = :"@#{reference}"
97
+ return @controller.instance_variable_get(ivar) if @controller.instance_variable_defined?(ivar)
98
+
99
+ raise ArgumentError, "parent: #{reference} is neither a controller method nor an @#{reference} ivar"
100
+ end
101
+
102
+ def association
103
+ @parameters[:parent_association] || @klass.model_name.plural.to_sym
104
+ end
105
+ end
106
+ end
107
+ end
@@ -51,7 +51,12 @@ module SimpleCrud
51
51
  unauthenticated_status: :unauthorized,
52
52
  assert_html_template: true,
53
53
  policy_class: ->(klass) { "#{klass}Policy".constantize },
54
- serializer_class: ->(model) { "#{model.class}_serializer".classify.constantize },
54
+ serializer_class: lambda { |model|
55
+ klass = model.class
56
+ next "#{klass}Serializer".constantize if Kernel.const_defined?("#{klass}Serializer")
57
+
58
+ "#{klass}Blueprint".constantize
59
+ },
55
60
  created_record_check: nil
56
61
  }.freeze
57
62
 
@@ -19,7 +19,8 @@ module SimpleCrud
19
19
  end
20
20
 
21
21
  def owner_foreign_key
22
- :"#{owner_association}_id"
22
+ reflection = model_class_object.reflect_on_association(owner_association)
23
+ reflection ? reflection.foreign_key : :"#{owner_association}_id"
23
24
  end
24
25
 
25
26
  # The owner association attributed to current_user, if the model has one.
@@ -10,8 +10,8 @@ module SimpleCrud
10
10
  described_class.instance_variable_get(:@simple_crud_metadata)[method][option]
11
11
  end
12
12
 
13
- %i[paginate authorize authenticate serializer html finder scope build raise_on_invalid block
14
- authenticate_headers].each do |option|
13
+ %i[paginate authorize authenticate serializer serializer_options status after_persist html finder
14
+ scope build raise_on_invalid block authenticate_headers owned_by parent].each do |option|
15
15
  define_method("check_#{option}") { |method| get_option(method, option) }
16
16
  end
17
17