wor-simple_crud 0.6.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 +15 -0
- data/README.md +89 -6
- data/lib/simple_crud/action_context.rb +16 -2
- data/lib/simple_crud/index_context.rb +21 -10
- data/lib/simple_crud/persistence_context.rb +16 -5
- data/lib/simple_crud/relation_source.rb +107 -0
- data/lib/simple_crud/rspec/helpers/controller_options.rb +1 -1
- data/lib/simple_crud/rspec.rb +2 -0
- data/lib/simple_crud/serializer.rb +2 -7
- data/lib/simple_crud/simple_crud_controller.rb +28 -7
- data/lib/simple_crud/version.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_nested_resource.rb +21 -0
- data/lib/spec/shared_examples/simple_crud_for_owned_resource.rb +35 -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 +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 +15 -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/serializer_spec.rb +21 -0
- data/spec/simple_crud/show_context_spec.rb +5 -0
- data/spec/simple_crud_controller_spec.rb +15 -0
- metadata +16 -2
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,20 @@
|
|
|
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
|
+
|
|
3
18
|
### V0.6.0
|
|
4
19
|
|
|
5
20
|
Serializer support:
|
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
|
|
@@ -108,11 +116,13 @@ simple_crud_for :index, paginate: false, authorize: false, serializer: CustomSer
|
|
|
108
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
|
|
109
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
|
|
110
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
|
|
111
|
-
- 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`)
|
|
112
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])`.
|
|
113
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
|
|
114
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
|
|
115
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`
|
|
116
126
|
|
|
117
127
|
#### Controller-level defaults
|
|
118
128
|
|
|
@@ -203,12 +213,19 @@ end
|
|
|
203
213
|
|
|
204
214
|
```
|
|
205
215
|
|
|
206
|
-
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:
|
|
207
217
|
|
|
208
218
|
```ruby
|
|
209
219
|
SimpleCrud.configure { |c| c.user_method = :current_admin } # non-Devise naming conventions
|
|
210
220
|
simple_crud_for :index, scope: ->(user) { Model.visible_to(user) }
|
|
211
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) }
|
|
212
229
|
```
|
|
213
230
|
|
|
214
231
|
Prefer CanCanCan or Action Policy instead? Both have adapters ready to go:
|
|
@@ -272,7 +289,7 @@ simple_crud_defaults authenticate: false, authenticate_headers: true
|
|
|
272
289
|
```
|
|
273
290
|
|
|
274
291
|
#### Serializer
|
|
275
|
-
`serializer:` picks the serializer for JSON responses on every action. Pass
|
|
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.
|
|
276
293
|
|
|
277
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.
|
|
278
295
|
|
|
@@ -286,6 +303,13 @@ class AuthorBlueprint < Blueprinter::Base
|
|
|
286
303
|
fields :email, :first_name, :last_name
|
|
287
304
|
end
|
|
288
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
|
+
|
|
289
313
|
simple_crud_for :show, serializer: AuthorBlueprint
|
|
290
314
|
simple_crud_for :index, serializer: AuthorBlueprint, serializer_options: -> { { current_user: current_user } }
|
|
291
315
|
```
|
|
@@ -306,11 +330,13 @@ simple_crud_for :index, html: true
|
|
|
306
330
|
simple_crud_for :show, html: true
|
|
307
331
|
simple_crud_for :new, html: true
|
|
308
332
|
simple_crud_for :edit, html: true
|
|
309
|
-
simple_crud_for :create, html: true
|
|
310
|
-
simple_crud_for :update, html: true
|
|
311
|
-
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.'
|
|
312
336
|
```
|
|
313
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
|
+
|
|
314
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:
|
|
315
341
|
|
|
316
342
|
```ruby
|
|
@@ -357,6 +383,63 @@ simple_crud_for :destroy, finder: ->(params) { current_user.models.find(params[:
|
|
|
357
383
|
|
|
358
384
|
When omitted it defaults to `klass.find(params[:id])`, and `not_found` is still returned whenever the finder finds no record.
|
|
359
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
|
+
|
|
360
443
|
#### Cache
|
|
361
444
|
Pass `cache: { key:, ttl: }` on `:show` or `:index` to skip the DB on cache hits.
|
|
362
445
|
|
|
@@ -57,11 +57,25 @@ module SimpleCrud
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def find_by_id
|
|
60
|
-
|
|
60
|
+
finder_scope.find(controller.params[:id])
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def build_record
|
|
64
|
-
|
|
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)
|
|
65
79
|
end
|
|
66
80
|
|
|
67
81
|
def permitted_params
|
|
@@ -46,20 +46,31 @@ module SimpleCrud
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def index_relation
|
|
49
|
-
if parameters[:scope]
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
def call_scope
|
|
59
|
-
user_method = SimpleCrud::Config.user_method
|
|
60
|
-
user = controller.respond_to?(user_method, true) ? controller.send(user_method) : nil
|
|
61
62
|
scope = parameters[:scope]
|
|
62
|
-
|
|
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
|
|
63
74
|
end
|
|
64
75
|
|
|
65
76
|
def index_records(relation, opts)
|
|
@@ -19,11 +19,22 @@ module SimpleCrud
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def render_html_redirect(record, saved, options)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
27
38
|
end
|
|
28
39
|
|
|
29
40
|
def redirect_target(record, target)
|
|
@@ -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
|
|
@@ -11,7 +11,7 @@ module SimpleCrud
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
%i[paginate authorize authenticate serializer serializer_options status after_persist html finder
|
|
14
|
-
scope build raise_on_invalid block authenticate_headers].each do |option|
|
|
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
|
|
data/lib/simple_crud/rspec.rb
CHANGED
|
@@ -27,6 +27,8 @@ require_relative '../spec/shared_examples/simple_crud_for_destroy_with_finder'
|
|
|
27
27
|
require_relative '../spec/shared_examples/simple_crud_not_found_with_finder'
|
|
28
28
|
require_relative '../spec/shared_examples/simple_crud_for_new_with_build'
|
|
29
29
|
require_relative '../spec/shared_examples/simple_crud_for_create_with_build'
|
|
30
|
+
require_relative '../spec/shared_examples/simple_crud_for_owned_resource'
|
|
31
|
+
require_relative '../spec/shared_examples/simple_crud_for_nested_resource'
|
|
30
32
|
require_relative '../spec/shared_examples/authorization_adapter_authorize'
|
|
31
33
|
require_relative '../spec/matchers/have_been_serialized_with'
|
|
32
34
|
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SimpleCrud
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# Blueprinter blueprints respond to `render_as_hash` and receive the
|
|
7
|
-
# render options, so a blueprint can read `url_builder:` (or any other
|
|
8
|
-
# option) from them. For anything else, simple_crud assumes an
|
|
9
|
-
# ActiveModelSerializers-style serializer and calls `new(record, options)`.
|
|
10
|
-
# A `nil` serializer renders `record.as_json`.
|
|
4
|
+
# Precedence: Blueprinter, then a plain class .render, then ActiveModelSerializers.
|
|
11
5
|
module Serializer
|
|
12
6
|
def self.render(serializer, record, options = {})
|
|
13
7
|
return record.as_json if serializer.nil?
|
|
14
8
|
return serializer.render_as_hash(record, options) if serializer.respond_to?(:render_as_hash)
|
|
9
|
+
return serializer.render(record, **options) if serializer.is_a?(Class) && serializer.respond_to?(:render)
|
|
15
10
|
|
|
16
11
|
serializer.new(record, options).as_json
|
|
17
12
|
end
|
|
@@ -4,6 +4,7 @@ require 'active_support/all'
|
|
|
4
4
|
require_relative 'config'
|
|
5
5
|
require_relative 'serializer'
|
|
6
6
|
require_relative 'cache_helpers'
|
|
7
|
+
require_relative 'relation_source'
|
|
7
8
|
require_relative 'action_context'
|
|
8
9
|
require_relative 'persistence_context'
|
|
9
10
|
require_relative 'show_context'
|
|
@@ -32,16 +33,26 @@ module SimpleCrudController
|
|
|
32
33
|
### serializer: use a particular serializer (both each_serializer and serializer)
|
|
33
34
|
### html: render the action's ERB template instead of JSON (index/show/new/edit/create/update/destroy)
|
|
34
35
|
### finder: custom record lookup (Proc/lambda or Symbol) for :show/:update/:destroy/:edit
|
|
35
|
-
### scope: custom index scope
|
|
36
|
+
### scope: custom index scope for :index, overriding policy_scope. A Proc/lambda run with the
|
|
37
|
+
### controller as self (arity 0 no args, arity 1 the user, else user and params), or a Symbol
|
|
38
|
+
### naming a model class method taking (current_user, params)
|
|
36
39
|
### build: custom record builder (Proc/lambda) for :new/:create, invoked with the controller as self
|
|
40
|
+
### owned_by: association on Config.user_method for the default finder/build/index-scope
|
|
41
|
+
### parent: nested-route parent for the same defaults; exclusive with owned_by:
|
|
42
|
+
### parent_association: parent association when it differs from the controller model's plural
|
|
43
|
+
### notice:/alert: flash on the html: create/update/destroy success redirect / failure re-render
|
|
37
44
|
### raise_on_invalid: use strict create!/update! semantics instead of returning 422
|
|
38
45
|
### A block given to simple_crud_for renders explicitly: it receives the records for :index,
|
|
39
46
|
### the record for :show/:new/:edit, or the record and a saved flag for :create/:update/:destroy
|
|
47
|
+
### An Array of actions declares several at once with the same options
|
|
40
48
|
def simple_crud_for(method, parameters = {}, &block)
|
|
49
|
+
return method.each { |name| simple_crud_for(name, parameters.dup, &block) } if method.is_a?(Array)
|
|
50
|
+
|
|
41
51
|
parameters[:block] = true if block
|
|
42
52
|
parameters = parameters_with_defaults(parameters)
|
|
43
53
|
klass = simple_crud_controller_model
|
|
44
54
|
check_valid_method(method)
|
|
55
|
+
check_relation_source(parameters)
|
|
45
56
|
check_policies(parameters)
|
|
46
57
|
check_serializer(parameters)
|
|
47
58
|
define_method(method, send("crud_lambda_for_#{method}", klass, parameters, &block))
|
|
@@ -62,18 +73,22 @@ module SimpleCrudController
|
|
|
62
73
|
end
|
|
63
74
|
|
|
64
75
|
def parameters_with_defaults(parameters)
|
|
65
|
-
defaults = {
|
|
66
|
-
authorize: true, paginate: true, authenticate: true, authenticate_headers: nil,
|
|
67
|
-
serializer: nil, serializer_options: nil, status: nil, after_persist: nil, html: false,
|
|
68
|
-
finder: nil, scope: nil, build: nil, raise_on_invalid: false
|
|
69
|
-
}
|
|
70
76
|
defaults.merge(simple_crud_inherited_defaults).each do |key, value|
|
|
71
77
|
parameters[key] = value unless parameters.key?(key)
|
|
72
78
|
end
|
|
73
|
-
parameters[:authenticate_headers]
|
|
79
|
+
parameters[:authenticate_headers] ||= parameters[:authenticate]
|
|
74
80
|
parameters
|
|
75
81
|
end
|
|
76
82
|
|
|
83
|
+
def defaults
|
|
84
|
+
{
|
|
85
|
+
authorize: true, paginate: true, authenticate: true, authenticate_headers: nil,
|
|
86
|
+
serializer: nil, serializer_options: nil, status: nil, after_persist: nil, html: false,
|
|
87
|
+
finder: nil, scope: nil, build: nil, owned_by: nil, parent: nil, parent_association: nil,
|
|
88
|
+
notice: nil, alert: nil, raise_on_invalid: false
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
77
92
|
def write_metadata(method, parameters)
|
|
78
93
|
@simple_crud_metadata ||= {}
|
|
79
94
|
@simple_crud_metadata[method] = parameters
|
|
@@ -87,6 +102,12 @@ module SimpleCrudController
|
|
|
87
102
|
raise ArgumentError, 'invalid method' unless %i[show index create update destroy new edit].include? method
|
|
88
103
|
end
|
|
89
104
|
|
|
105
|
+
def check_relation_source(parameters)
|
|
106
|
+
return unless parameters[:owned_by] && parameters[:parent]
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, 'owned_by: and parent: are mutually exclusive, they both scope the same action'
|
|
109
|
+
end
|
|
110
|
+
|
|
90
111
|
def check_policies(parameters)
|
|
91
112
|
return if !parameters.key?(:authorize) || !parameters[:authorize]
|
|
92
113
|
|
data/lib/simple_crud/version.rb
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# For a parent: controller. The parent in route_params must match the record's owner_association.
|
|
4
|
+
RSpec.shared_examples 'simple crud for nested resource' do
|
|
5
|
+
describe 'parent: scoping' do
|
|
6
|
+
include_context 'with authenticated user' if check_authenticate(:show)
|
|
7
|
+
|
|
8
|
+
it 'finds a record inside the parent' do
|
|
9
|
+
get :show, params: with_route_params(record_param(:show, model)), format: format_param(:show)
|
|
10
|
+
|
|
11
|
+
expect(response).to have_http_status(:ok)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'returns not found for a record outside the parent' do
|
|
15
|
+
outside = create_record(model_class, model_attributes.merge(owner_association => other_user))
|
|
16
|
+
get :show, params: with_route_params(record_param(:show, outside)), format: format_param(:show)
|
|
17
|
+
|
|
18
|
+
expect(response).to have_http_status(:not_found)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Include alongside the per-action examples for a controller using owned_by:.
|
|
4
|
+
RSpec.shared_examples 'simple crud for owned resource' do
|
|
5
|
+
describe 'owned_by: scoping' do
|
|
6
|
+
include_context 'with authenticated user'
|
|
7
|
+
|
|
8
|
+
let!(:someone_elses_model) do
|
|
9
|
+
create_record(model_class, model_attributes.merge(owner_association => other_user))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'returns not found for a record owned by another user' do
|
|
13
|
+
get :show, params: with_route_params(record_param(:show, someone_elses_model)), format: format_param(:show)
|
|
14
|
+
|
|
15
|
+
expect(response).to have_http_status(:not_found)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'ignores an attempt to update a record owned by another user', :aggregate_failures do
|
|
19
|
+
params = with_route_params(record_param(:update, someone_elses_model)).merge(body_params(model_params))
|
|
20
|
+
patch :update, params: params, format: format_param(:update)
|
|
21
|
+
|
|
22
|
+
expect(response).to have_http_status(:not_found)
|
|
23
|
+
expect(someone_elses_model.reload.public_send(required_attribute))
|
|
24
|
+
.not_to eq(model_params[required_attribute])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'attributes a created record to the current user regardless of the request', :aggregate_failures do
|
|
28
|
+
body = body_params(model_params.merge(owner_association => other_user.id))
|
|
29
|
+
post :create, params: with_route_params(body), format: format_param(:create)
|
|
30
|
+
|
|
31
|
+
expect(response).to have_http_status(check_status(:create) || :created)
|
|
32
|
+
expect(model_class_object.last.public_send(owner_association)).to eq(current_user)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HtmlNotice
|
|
4
|
+
class DummyModelsController < HtmlModes::BaseController
|
|
5
|
+
simple_crud_for :create, html: true, authenticate: false, authorize: false,
|
|
6
|
+
notice: 'created', alert: 'invalid'
|
|
7
|
+
simple_crud_for :update, html: true, authenticate: false, authorize: false,
|
|
8
|
+
notice: 'updated', alert: 'invalid'
|
|
9
|
+
simple_crud_for :destroy, html: true, authenticate: false, authorize: false, notice: 'removed'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Owned
|
|
4
|
+
class DummyModelsController < ApplicationController
|
|
5
|
+
include Wor::Paginate
|
|
6
|
+
include Pundit::Authorization
|
|
7
|
+
extend SimpleCrudController
|
|
8
|
+
|
|
9
|
+
# No :user_id: a permitted FK would let a request override the owner set by owned_by:.
|
|
10
|
+
def dummy_model_params
|
|
11
|
+
params.permit(:name, :something, :slug)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
simple_crud_defaults owned_by: :dummy_models, authorize: false
|
|
15
|
+
simple_crud_for :index
|
|
16
|
+
simple_crud_for :show
|
|
17
|
+
simple_crud_for :create
|
|
18
|
+
simple_crud_for :update
|
|
19
|
+
simple_crud_for :destroy
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Parented
|
|
4
|
+
class DummyModelsController < ApplicationController
|
|
5
|
+
include Wor::Paginate
|
|
6
|
+
include Pundit::Authorization
|
|
7
|
+
extend SimpleCrudController
|
|
8
|
+
|
|
9
|
+
# The default association for this model is @user.dummy_models.
|
|
10
|
+
def owner = @owner ||= User.find(params[:owner_id])
|
|
11
|
+
|
|
12
|
+
def dummy_model_params
|
|
13
|
+
params.permit(:name, :something, :slug)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
simple_crud_defaults parent: :owner, authorize: false
|
|
17
|
+
simple_crud_for :index
|
|
18
|
+
simple_crud_for :show
|
|
19
|
+
simple_crud_for :create
|
|
20
|
+
simple_crud_for :update
|
|
21
|
+
simple_crud_for :destroy
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Edit
|
data/spec/dummy/config/routes.rb
CHANGED
|
@@ -40,6 +40,14 @@ Rails.application.routes.draw do
|
|
|
40
40
|
resources :dummy_models, only: :index
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
namespace :owned do
|
|
44
|
+
resources :dummy_models
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
namespace :scoped_symbol do
|
|
48
|
+
resources :dummy_models, only: :index
|
|
49
|
+
end
|
|
50
|
+
|
|
43
51
|
namespace :unpaginated_scoped do
|
|
44
52
|
resources :dummy_models, only: :index
|
|
45
53
|
end
|
|
@@ -88,6 +96,10 @@ Rails.application.routes.draw do
|
|
|
88
96
|
resources :dummy_models, only: :destroy
|
|
89
97
|
end
|
|
90
98
|
|
|
99
|
+
namespace :html_notice do
|
|
100
|
+
resources :dummy_models, only: %i[create update destroy]
|
|
101
|
+
end
|
|
102
|
+
|
|
91
103
|
namespace :block_destroy do
|
|
92
104
|
resources :dummy_models, only: :destroy
|
|
93
105
|
end
|
|
@@ -133,4 +145,8 @@ Rails.application.routes.draw do
|
|
|
133
145
|
resources :dummy_models, param: :slug, only: %i[show create destroy]
|
|
134
146
|
end
|
|
135
147
|
end
|
|
148
|
+
|
|
149
|
+
namespace :parented do
|
|
150
|
+
resources :dummy_models
|
|
151
|
+
end
|
|
136
152
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe HtmlNotice::DummyModelsController, type: :controller do
|
|
6
|
+
describe 'POST #create' do
|
|
7
|
+
it 'flashes the notice on success' do
|
|
8
|
+
post :create, params: attributes_for(:dummy_model).merge(user_id: create(:user).id), format: :html
|
|
9
|
+
|
|
10
|
+
expect(flash[:notice]).to eq('created')
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe 'PUT #update' do
|
|
15
|
+
let(:record) { create(:dummy_model) }
|
|
16
|
+
|
|
17
|
+
it 'flashes the notice on success' do
|
|
18
|
+
put :update, params: { id: record.id, name: 'renamed' }, format: :html
|
|
19
|
+
|
|
20
|
+
expect(flash[:notice]).to eq('updated')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'flashes the alert on failure' do
|
|
24
|
+
put :update, params: { id: record.id, name: nil }, format: :html
|
|
25
|
+
|
|
26
|
+
expect(flash.now[:alert]).to eq('invalid')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe 'DELETE #destroy' do
|
|
31
|
+
it 'flashes the notice on success' do
|
|
32
|
+
record = create(:dummy_model)
|
|
33
|
+
delete :destroy, params: { id: record.id }, format: :html
|
|
34
|
+
|
|
35
|
+
expect(flash[:notice]).to eq('removed')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe Owned::DummyModelsController, type: :controller do
|
|
6
|
+
include_examples 'simple crud for show'
|
|
7
|
+
include_examples 'simple crud for create'
|
|
8
|
+
include_examples 'simple crud for update'
|
|
9
|
+
include_examples 'simple crud for destroy'
|
|
10
|
+
include_examples 'simple crud for index'
|
|
11
|
+
include_examples 'simple crud for owned resource'
|
|
12
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe Parented::DummyModelsController, simple_crud: {
|
|
6
|
+
route_params: -> { { owner_id: current_user.id } }
|
|
7
|
+
}, type: :controller do
|
|
8
|
+
let(:owner) { create(:user) }
|
|
9
|
+
|
|
10
|
+
include_examples 'simple crud for nested resource'
|
|
11
|
+
|
|
12
|
+
describe 'POST #create' do
|
|
13
|
+
include_context 'with authenticated user'
|
|
14
|
+
|
|
15
|
+
it 'builds the record through the parent association' do
|
|
16
|
+
post :create, params: { owner_id: owner.id, name: 'from parent' }, format: :json
|
|
17
|
+
|
|
18
|
+
expect(owner.dummy_models.find(response.parsed_body['id']).name).to eq('from parent')
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
describe 'POST #create with parent: and owned_by: together' do
|
|
23
|
+
it 'raises, they are the same relation source' do
|
|
24
|
+
expect do
|
|
25
|
+
described_class.simple_crud_for(:show, parent: :owner, owned_by: :dummy_models)
|
|
26
|
+
end.to raise_error(ArgumentError, /mutually exclusive/)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -20,6 +20,21 @@ describe SimpleCrud::ActionContext do
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
describe '#build_record' do
|
|
24
|
+
it 'raises not found when owned_by has no owner to build from' do
|
|
25
|
+
ctx = described_class.new(double(params: {}), DummyModel, { owned_by: :dummy_models })
|
|
26
|
+
expect { ctx.send(:build_record) }.to raise_error(ActiveRecord::RecordNotFound, /no owner/)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'builds through the scoped relation when configured' do
|
|
30
|
+
relation = double(build: :built)
|
|
31
|
+
ctrl = double(params: {})
|
|
32
|
+
ctrl.instance_variable_set(:@owner, double(dummy_models: relation))
|
|
33
|
+
|
|
34
|
+
expect(described_class.new(ctrl, DummyModel, { parent: :owner }).send(:build_record)).to eq(:built)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
23
38
|
describe '#serializer_options' do
|
|
24
39
|
let(:record) { DummyModel.new(name: 'widget') }
|
|
25
40
|
|
|
@@ -27,5 +27,54 @@ describe SimpleCrud::IndexContext do
|
|
|
27
27
|
ctx = described_class.new(ctrl, DummyModel, { scope: ->(u) { u }, authorize: false })
|
|
28
28
|
expect(ctx.send(:call_scope)).to eq(admin)
|
|
29
29
|
end
|
|
30
|
+
|
|
31
|
+
it 'runs an arity-zero scope with the controller as self and no arguments' do
|
|
32
|
+
ctrl = double(params: {}, listing: DummyModel.none)
|
|
33
|
+
ctx = described_class.new(ctrl, DummyModel, { scope: -> { listing }, authorize: false })
|
|
34
|
+
|
|
35
|
+
expect(ctx.send(:call_scope)).to eq(DummyModel.none)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'dispatches a Symbol scope to the model with user and params' do
|
|
39
|
+
ctrl = double(params: { status: 'active' })
|
|
40
|
+
allow(DummyModel).to receive(:visible_to).with(nil, { status: 'active' }).and_return(DummyModel.none)
|
|
41
|
+
ctx = described_class.new(ctrl, DummyModel, { scope: :visible_to, authorize: false })
|
|
42
|
+
|
|
43
|
+
expect(ctx.send(:call_scope)).to eq(DummyModel.none)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe '#index_relation' do
|
|
48
|
+
let(:owned_records) { DummyModel.none }
|
|
49
|
+
|
|
50
|
+
it 'scopes to the owned_by relation even when authorize is on' do
|
|
51
|
+
ctrl = double(params: {}, current_user: double(dummy_models: owned_records))
|
|
52
|
+
ctx = described_class.new(ctrl, DummyModel, { owned_by: :dummy_models, authorize: true })
|
|
53
|
+
|
|
54
|
+
expect(ctx.send(:index_relation)).to eq(owned_records)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'returns no records when there is no user to own them' do
|
|
58
|
+
ctx = described_class.new(double(params: {}), DummyModel, { owned_by: :dummy_models, authorize: false })
|
|
59
|
+
|
|
60
|
+
expect(ctx.send(:index_relation)).to be_empty
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'scopes to the parent relation' do
|
|
64
|
+
parent = double(dummy_models: owned_records)
|
|
65
|
+
ctrl = double(params: {})
|
|
66
|
+
ctrl.instance_variable_set(:@owner, parent)
|
|
67
|
+
ctx = described_class.new(ctrl, DummyModel, { parent: :owner, authorize: false })
|
|
68
|
+
|
|
69
|
+
expect(ctx.send(:index_relation)).to eq(owned_records)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'returns no records when the parent association is empty' do
|
|
73
|
+
ctrl = double(params: {})
|
|
74
|
+
ctrl.instance_variable_set(:@owner, double(dummy_models: nil))
|
|
75
|
+
ctx = described_class.new(ctrl, DummyModel, { parent: :owner, authorize: false })
|
|
76
|
+
|
|
77
|
+
expect(ctx.send(:index_relation)).to be_empty
|
|
78
|
+
end
|
|
30
79
|
end
|
|
31
80
|
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe SimpleCrud::RelationSource do
|
|
6
|
+
after { SimpleCrud::Config.user_method = :current_user }
|
|
7
|
+
|
|
8
|
+
let(:controller) { double(params: {}) }
|
|
9
|
+
let(:source) { described_class.new(controller, DummyModel, parameters) }
|
|
10
|
+
|
|
11
|
+
describe '#configured?' do
|
|
12
|
+
it 'is true for owned_by' do
|
|
13
|
+
expect(described_class.new(controller, DummyModel, { owned_by: :dummy_models })).to be_configured
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'is true for parent' do
|
|
17
|
+
expect(described_class.new(controller, DummyModel, { parent: :owner })).to be_configured
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'is false when neither is set' do
|
|
21
|
+
expect(described_class.new(controller, DummyModel, {})).not_to be_configured
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe '#relation' do
|
|
26
|
+
it 'is nil when neither option is set' do
|
|
27
|
+
expect(described_class.new(controller, DummyModel, {}).relation).to be_nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
context 'with owned_by' do
|
|
31
|
+
let(:parameters) { { owned_by: :dummy_models } }
|
|
32
|
+
|
|
33
|
+
it 'resolves the association on the user' do
|
|
34
|
+
models = DummyModel.none
|
|
35
|
+
allow(controller).to receive(:current_user).and_return(double(dummy_models: models))
|
|
36
|
+
|
|
37
|
+
expect(source.relation).to eq(models)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'is nil without a current user' do
|
|
41
|
+
expect(source.relation).to be_nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'resolves the user via overridden Config.user_method' do
|
|
45
|
+
SimpleCrud::Config.user_method = :current_admin
|
|
46
|
+
models = DummyModel.none
|
|
47
|
+
allow(controller).to receive(:current_admin).and_return(double(dummy_models: models))
|
|
48
|
+
|
|
49
|
+
expect(source.relation).to eq(models)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
context 'with parent' do
|
|
54
|
+
let(:parameters) { { parent: :owner } }
|
|
55
|
+
|
|
56
|
+
it 'prefers a controller method over an ivar' do
|
|
57
|
+
parent = double(dummy_models: DummyModel.none)
|
|
58
|
+
allow(controller).to receive(:owner).and_return(parent)
|
|
59
|
+
|
|
60
|
+
expect(source.relation).to eq(DummyModel.none)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'falls back to the ivar when there is no method' do
|
|
64
|
+
parent = double(dummy_models: DummyModel.none)
|
|
65
|
+
controller.instance_variable_set(:@owner, parent)
|
|
66
|
+
|
|
67
|
+
expect(source.relation).to eq(DummyModel.none)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'runs a Proc parent in controller context' do
|
|
71
|
+
parent = double(dummy_models: DummyModel.none)
|
|
72
|
+
allow(controller).to receive(:owner).and_return(parent)
|
|
73
|
+
proc_source = described_class.new(controller, DummyModel, { parent: -> { owner } })
|
|
74
|
+
|
|
75
|
+
expect(proc_source.relation).to eq(DummyModel.none)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'is nil when the parent resolves to nil' do
|
|
79
|
+
controller.instance_variable_set(:@owner, nil)
|
|
80
|
+
|
|
81
|
+
expect(source.relation).to be_nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'derives the association from the model' do
|
|
85
|
+
parent = double
|
|
86
|
+
controller.instance_variable_set(:@owner, parent)
|
|
87
|
+
allow(parent).to receive(:dummy_models).and_return(DummyModel.none)
|
|
88
|
+
|
|
89
|
+
expect(source.relation).to eq(DummyModel.none)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'honors parent_association:' do
|
|
93
|
+
models = DummyModel.none
|
|
94
|
+
controller.instance_variable_set(:@owner, double(special: models))
|
|
95
|
+
source = described_class.new(controller, DummyModel, { parent: :owner, parent_association: :special })
|
|
96
|
+
|
|
97
|
+
expect(source.relation).to eq(models)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
describe '#relation!' do
|
|
103
|
+
let(:parameters) { { owned_by: :dummy_models } }
|
|
104
|
+
|
|
105
|
+
it 'raises not found when the owner cannot be resolved' do
|
|
106
|
+
expect { source.relation! }.to raise_error(ActiveRecord::RecordNotFound, /no owner for dummy_models/)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe '#user' do
|
|
111
|
+
let(:parameters) { {} }
|
|
112
|
+
|
|
113
|
+
it 'returns nil when the controller has no user method' do
|
|
114
|
+
expect(source.user).to be_nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe 'unresolvable parent' do
|
|
119
|
+
let(:parameters) { { parent: :owner } }
|
|
120
|
+
|
|
121
|
+
it 'raises when the parent is neither a method nor an ivar' do
|
|
122
|
+
expect { source.relation }.to raise_error(ArgumentError, /neither a controller method nor an @owner ivar/)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -4,6 +4,17 @@ require 'spec_helper'
|
|
|
4
4
|
|
|
5
5
|
describe SimpleCrud::Serializer do
|
|
6
6
|
let(:record) { DummyModel.new(id: 1, name: 'widget', something: 'thing', user_id: 9) }
|
|
7
|
+
let(:non_class_serializer) do
|
|
8
|
+
Module.new do
|
|
9
|
+
def self.render(_record, **)
|
|
10
|
+
:plain
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.new(*)
|
|
14
|
+
Struct.new(:as_json).new(:ams)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
7
18
|
|
|
8
19
|
describe '.render' do
|
|
9
20
|
it 'falls back to the record as_json when there is no serializer' do
|
|
@@ -16,6 +27,16 @@ describe SimpleCrud::Serializer do
|
|
|
16
27
|
expect(result).to include(id: record.id, name: record.name, something: record.something, user_id: record.user_id)
|
|
17
28
|
end
|
|
18
29
|
|
|
30
|
+
it 'calls a plain class .render method with the options splatted as keywords' do
|
|
31
|
+
result = described_class.render(DummyModelPlainSerializer, record, { root: false })
|
|
32
|
+
|
|
33
|
+
expect(result).to eq(id: record.id, name: record.name, something: record.something, root: false)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'falls through to the AMS path for a non-class that responds to render' do
|
|
37
|
+
expect(described_class.render(non_class_serializer, record)).to eq(:ams)
|
|
38
|
+
end
|
|
39
|
+
|
|
19
40
|
it 'instantiates an AMS-style serializer with the given options', :aggregate_failures do
|
|
20
41
|
allow(DummyModelSerializer).to receive(:new).and_call_original
|
|
21
42
|
|
|
@@ -10,5 +10,10 @@ describe SimpleCrud::ShowContext do
|
|
|
10
10
|
expect { ctx.send(:find_record) }
|
|
11
11
|
.to raise_error(ActiveRecord::RecordNotFound, /must return a single record/)
|
|
12
12
|
end
|
|
13
|
+
|
|
14
|
+
it 'raises not found when owned_by has no owner to scope the lookup to' do
|
|
15
|
+
ctx = described_class.new(double(params: { id: 1 }), DummyModel, { owned_by: :dummy_models, authorize: false })
|
|
16
|
+
expect { ctx.send(:find_record) }.to raise_error(ActiveRecord::RecordNotFound, /no owner/)
|
|
17
|
+
end
|
|
13
18
|
end
|
|
14
19
|
end
|
|
@@ -16,7 +16,22 @@ end
|
|
|
16
16
|
class InheritingController < BaseWithDefaultsController
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
class MultiActionModel; end
|
|
20
|
+
|
|
21
|
+
class MultiActionModelsController
|
|
22
|
+
extend SimpleCrudController
|
|
23
|
+
end
|
|
24
|
+
|
|
19
25
|
describe SimpleCrudController do
|
|
26
|
+
describe '.simple_crud_for' do
|
|
27
|
+
it 'declares every action when given an array', :aggregate_failures do
|
|
28
|
+
MultiActionModelsController.simple_crud_for(%i[show create], authorize: false, authenticate: false)
|
|
29
|
+
|
|
30
|
+
expect(MultiActionModelsController.method_defined?(:show)).to be true
|
|
31
|
+
expect(MultiActionModelsController.method_defined?(:create)).to be true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
20
35
|
describe '.check_policies' do
|
|
21
36
|
it 'returns when a matching policy exists' do
|
|
22
37
|
expect { DummyModelsController.check_policies(authorize: true) }.not_to raise_error
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wor-simple_crud
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- icoluccio
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-18 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- lib/simple_crud/pagination/will_paginate_adapter.rb
|
|
68
68
|
- lib/simple_crud/pagination/wor_paginate_adapter.rb
|
|
69
69
|
- lib/simple_crud/persistence_context.rb
|
|
70
|
+
- lib/simple_crud/relation_source.rb
|
|
70
71
|
- lib/simple_crud/rspec.rb
|
|
71
72
|
- lib/simple_crud/rspec/config.rb
|
|
72
73
|
- lib/simple_crud/rspec/helpers.rb
|
|
@@ -95,9 +96,11 @@ files:
|
|
|
95
96
|
- lib/spec/shared_examples/simple_crud_for_index.rb
|
|
96
97
|
- lib/spec/shared_examples/simple_crud_for_index_with_block.rb
|
|
97
98
|
- lib/spec/shared_examples/simple_crud_for_index_with_scope.rb
|
|
99
|
+
- lib/spec/shared_examples/simple_crud_for_nested_resource.rb
|
|
98
100
|
- lib/spec/shared_examples/simple_crud_for_new.rb
|
|
99
101
|
- lib/spec/shared_examples/simple_crud_for_new_with_block.rb
|
|
100
102
|
- lib/spec/shared_examples/simple_crud_for_new_with_build.rb
|
|
103
|
+
- lib/spec/shared_examples/simple_crud_for_owned_resource.rb
|
|
101
104
|
- lib/spec/shared_examples/simple_crud_for_show.rb
|
|
102
105
|
- lib/spec/shared_examples/simple_crud_for_show_with_block.rb
|
|
103
106
|
- lib/spec/shared_examples/simple_crud_for_show_with_finder.rb
|
|
@@ -148,16 +151,20 @@ files:
|
|
|
148
151
|
- spec/dummy/app/controllers/html_finder/dummy_models_controller.rb
|
|
149
152
|
- spec/dummy/app/controllers/html_modes/base_controller.rb
|
|
150
153
|
- spec/dummy/app/controllers/html_new/dummy_models_controller.rb
|
|
154
|
+
- spec/dummy/app/controllers/html_notice/dummy_models_controller.rb
|
|
151
155
|
- spec/dummy/app/controllers/html_scoped/dummy_models_controller.rb
|
|
152
156
|
- spec/dummy/app/controllers/html_show/dummy_models_controller.rb
|
|
153
157
|
- spec/dummy/app/controllers/html_update/dummy_models_controller.rb
|
|
154
158
|
- spec/dummy/app/controllers/invalid_status/dummy_models_controller.rb
|
|
155
159
|
- spec/dummy/app/controllers/nested/dummy_models_controller.rb
|
|
156
160
|
- spec/dummy/app/controllers/nested_route/dummy_models_controller.rb
|
|
161
|
+
- spec/dummy/app/controllers/owned/dummy_models_controller.rb
|
|
162
|
+
- spec/dummy/app/controllers/parented/dummy_models_controller.rb
|
|
157
163
|
- spec/dummy/app/controllers/redirect/dummy_models_controller.rb
|
|
158
164
|
- spec/dummy/app/controllers/redirect_auth/dummy_models_controller.rb
|
|
159
165
|
- spec/dummy/app/controllers/scoped/dummy_models_controller.rb
|
|
160
166
|
- spec/dummy/app/controllers/scoped_params/dummy_models_controller.rb
|
|
167
|
+
- spec/dummy/app/controllers/scoped_symbol/dummy_models_controller.rb
|
|
161
168
|
- spec/dummy/app/controllers/strict/dummy_models_controller.rb
|
|
162
169
|
- spec/dummy/app/controllers/unpaginated_scoped/dummy_models_controller.rb
|
|
163
170
|
- spec/dummy/app/controllers/without_pagination/dummy_models_controller.rb
|
|
@@ -166,6 +173,7 @@ files:
|
|
|
166
173
|
- spec/dummy/app/models/dummy_model.rb
|
|
167
174
|
- spec/dummy/app/models/dummy_model_policy.rb
|
|
168
175
|
- spec/dummy/app/models/user.rb
|
|
176
|
+
- spec/dummy/app/serializers/dummy_model_plain_serializer.rb
|
|
169
177
|
- spec/dummy/app/serializers/dummy_model_serializer.rb
|
|
170
178
|
- spec/dummy/app/views/block/dummy_models/index.html.erb
|
|
171
179
|
- spec/dummy/app/views/block_new/dummy_models/new.html.erb
|
|
@@ -177,6 +185,7 @@ files:
|
|
|
177
185
|
- spec/dummy/app/views/html_finder/dummy_models/new.html.erb
|
|
178
186
|
- spec/dummy/app/views/html_finder/dummy_models/show.html.erb
|
|
179
187
|
- spec/dummy/app/views/html_new/dummy_models/new.html.erb
|
|
188
|
+
- spec/dummy/app/views/html_notice/dummy_models/edit.html.erb
|
|
180
189
|
- spec/dummy/app/views/html_scoped/dummy_models/index.html.erb
|
|
181
190
|
- spec/dummy/app/views/html_show/dummy_models/show.html.erb
|
|
182
191
|
- spec/dummy/app/views/html_update/dummy_models/edit.html.erb
|
|
@@ -221,22 +230,27 @@ files:
|
|
|
221
230
|
- spec/html_destroy/dummy_models_controller_spec.rb
|
|
222
231
|
- spec/html_finder/dummy_models_controller_spec.rb
|
|
223
232
|
- spec/html_new/dummy_models_controller_spec.rb
|
|
233
|
+
- spec/html_notice/dummy_models_controller_spec.rb
|
|
224
234
|
- spec/html_scoped/dummy_models_controller_spec.rb
|
|
225
235
|
- spec/html_show/dummy_models_controller_spec.rb
|
|
226
236
|
- spec/html_update/dummy_models_controller_spec.rb
|
|
227
237
|
- spec/invalid_status/dummy_models_controller_spec.rb
|
|
228
238
|
- spec/nested/dummy_models_controller_spec.rb
|
|
229
239
|
- spec/nested_route/dummy_models_controller_spec.rb
|
|
240
|
+
- spec/owned/dummy_models_controller_spec.rb
|
|
241
|
+
- spec/parented/dummy_models_controller_spec.rb
|
|
230
242
|
- spec/redirect/dummy_models_controller_spec.rb
|
|
231
243
|
- spec/redirect_auth/dummy_models_controller_spec.rb
|
|
232
244
|
- spec/scoped/dummy_models_controller_spec.rb
|
|
233
245
|
- spec/scoped_params/dummy_models_controller_spec.rb
|
|
246
|
+
- spec/scoped_symbol/dummy_models_controller_spec.rb
|
|
234
247
|
- spec/simple_crud/action_context_spec.rb
|
|
235
248
|
- spec/simple_crud/authorization/action_policy_adapter_spec.rb
|
|
236
249
|
- spec/simple_crud/authorization/can_can_can_adapter_spec.rb
|
|
237
250
|
- spec/simple_crud/authorization/pundit_adapter_spec.rb
|
|
238
251
|
- spec/simple_crud/index_context_spec.rb
|
|
239
252
|
- spec/simple_crud/pagination/adapters_spec.rb
|
|
253
|
+
- spec/simple_crud/relation_source_spec.rb
|
|
240
254
|
- spec/simple_crud/rspec_config_spec.rb
|
|
241
255
|
- spec/simple_crud/serializer_spec.rb
|
|
242
256
|
- spec/simple_crud/show_context_spec.rb
|