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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/Gemfile +5 -1
- data/README.md +104 -8
- data/lib/simple_crud/action_context.rb +45 -10
- data/lib/simple_crud/create_context.rb +2 -1
- data/lib/simple_crud/destroy_context.rb +2 -1
- data/lib/simple_crud/index_context.rb +30 -12
- data/lib/simple_crud/pagination/wor_paginate_adapter.rb +7 -1
- data/lib/simple_crud/persistence_context.rb +33 -6
- data/lib/simple_crud/relation_source.rb +107 -0
- data/lib/simple_crud/rspec/config.rb +6 -1
- data/lib/simple_crud/rspec/helpers/authentication.rb +2 -1
- data/lib/simple_crud/rspec/helpers/controller_options.rb +2 -2
- data/lib/simple_crud/rspec/helpers/requests.rb +5 -2
- data/lib/simple_crud/rspec.rb +2 -0
- data/lib/simple_crud/serializer.rb +14 -0
- data/lib/simple_crud/show_context.rb +3 -1
- data/lib/simple_crud/simple_crud_controller.rb +29 -6
- data/lib/simple_crud/update_context.rb +2 -1
- data/lib/simple_crud/version.rb +1 -1
- data/lib/spec/matchers/have_been_serialized_with.rb +19 -2
- data/lib/spec/shared_examples/simple_crud_for_create.rb +6 -6
- data/lib/spec/shared_examples/simple_crud_for_create_with_block.rb +2 -2
- data/lib/spec/shared_examples/simple_crud_for_create_with_build.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_destroy.rb +8 -8
- data/lib/spec/shared_examples/simple_crud_for_destroy_with_block.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_destroy_with_finder.rb +3 -3
- data/lib/spec/shared_examples/simple_crud_for_edit.rb +2 -2
- data/lib/spec/shared_examples/simple_crud_for_index.rb +3 -3
- data/lib/spec/shared_examples/simple_crud_for_index_with_block.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_index_with_scope.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_nested_resource.rb +21 -0
- data/lib/spec/shared_examples/simple_crud_for_new.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_new_with_block.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_new_with_build.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_owned_resource.rb +35 -0
- data/lib/spec/shared_examples/simple_crud_for_show.rb +2 -2
- data/lib/spec/shared_examples/simple_crud_for_show_with_block.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_show_with_finder.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_update.rb +7 -7
- data/lib/spec/shared_examples/simple_crud_for_update_with_finder.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_not_found_with_finder.rb +1 -1
- data/spec/blueprint/dummy_models_controller_spec.rb +25 -0
- data/spec/blueprint_unpaginated/dummy_models_controller_spec.rb +9 -0
- data/spec/dummy/app/blueprints/dummy_model_blueprint.rb +10 -0
- data/spec/dummy/app/controllers/blueprint/dummy_models_controller.rb +21 -0
- data/spec/dummy/app/controllers/blueprint_unpaginated/dummy_models_controller.rb +10 -0
- data/spec/dummy/app/controllers/html_notice/dummy_models_controller.rb +11 -0
- data/spec/dummy/app/controllers/owned/dummy_models_controller.rb +21 -0
- data/spec/dummy/app/controllers/parented/dummy_models_controller.rb +23 -0
- data/spec/dummy/app/controllers/scoped_symbol/dummy_models_controller.rb +11 -0
- data/spec/dummy/app/models/dummy_model.rb +4 -0
- data/spec/dummy/app/serializers/dummy_model_plain_serializer.rb +8 -0
- data/spec/dummy/app/views/html_notice/dummy_models/edit.html.erb +1 -0
- data/spec/dummy/config/routes.rb +24 -0
- data/spec/dummy_model_blueprint_spec.rb +16 -0
- data/spec/html_notice/dummy_models_controller_spec.rb +38 -0
- data/spec/owned/dummy_models_controller_spec.rb +12 -0
- data/spec/parented/dummy_models_controller_spec.rb +29 -0
- data/spec/scoped_symbol/dummy_models_controller_spec.rb +7 -0
- data/spec/simple_crud/action_context_spec.rb +34 -0
- data/spec/simple_crud/index_context_spec.rb +49 -0
- data/spec/simple_crud/relation_source_spec.rb +125 -0
- data/spec/simple_crud/rspec_config_spec.rb +13 -0
- data/spec/simple_crud/serializer_spec.rb +49 -0
- data/spec/simple_crud/show_context_spec.rb +5 -0
- data/spec/simple_crud_controller_spec.rb +15 -0
- metadata +25 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32e4043eb57f025e3a834a364be09cae1e35fa401bbf95bac88351272297d4f9
|
|
4
|
+
data.tar.gz: bb88b34edd232839b1b760a2bcb834bd25f8a244d3ad02c4a91097f14d85b377
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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`
|
|
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
|
|
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
|
-
|
|
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, :
|
|
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
|
|
53
|
+
return find_by_id if finder.nil?
|
|
54
54
|
return klass.send(finder, controller.params) unless finder.respond_to?(:call)
|
|
55
55
|
|
|
56
|
-
|
|
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
|
-
|
|
64
|
+
return controller.instance_exec(¶meters[: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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|