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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +89 -6
  4. data/lib/simple_crud/action_context.rb +16 -2
  5. data/lib/simple_crud/index_context.rb +21 -10
  6. data/lib/simple_crud/persistence_context.rb +16 -5
  7. data/lib/simple_crud/relation_source.rb +107 -0
  8. data/lib/simple_crud/rspec/helpers/controller_options.rb +1 -1
  9. data/lib/simple_crud/rspec.rb +2 -0
  10. data/lib/simple_crud/serializer.rb +2 -7
  11. data/lib/simple_crud/simple_crud_controller.rb +28 -7
  12. data/lib/simple_crud/version.rb +1 -1
  13. data/lib/spec/shared_examples/simple_crud_for_nested_resource.rb +21 -0
  14. data/lib/spec/shared_examples/simple_crud_for_owned_resource.rb +35 -0
  15. data/spec/dummy/app/controllers/html_notice/dummy_models_controller.rb +11 -0
  16. data/spec/dummy/app/controllers/owned/dummy_models_controller.rb +21 -0
  17. data/spec/dummy/app/controllers/parented/dummy_models_controller.rb +23 -0
  18. data/spec/dummy/app/controllers/scoped_symbol/dummy_models_controller.rb +11 -0
  19. data/spec/dummy/app/models/dummy_model.rb +4 -0
  20. data/spec/dummy/app/serializers/dummy_model_plain_serializer.rb +8 -0
  21. data/spec/dummy/app/views/html_notice/dummy_models/edit.html.erb +1 -0
  22. data/spec/dummy/config/routes.rb +16 -0
  23. data/spec/html_notice/dummy_models_controller_spec.rb +38 -0
  24. data/spec/owned/dummy_models_controller_spec.rb +12 -0
  25. data/spec/parented/dummy_models_controller_spec.rb +29 -0
  26. data/spec/scoped_symbol/dummy_models_controller_spec.rb +7 -0
  27. data/spec/simple_crud/action_context_spec.rb +15 -0
  28. data/spec/simple_crud/index_context_spec.rb +49 -0
  29. data/spec/simple_crud/relation_source_spec.rb +125 -0
  30. data/spec/simple_crud/serializer_spec.rb +21 -0
  31. data/spec/simple_crud/show_context_spec.rb +5 -0
  32. data/spec/simple_crud_controller_spec.rb +15 -0
  33. metadata +16 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b40c7867a484ffc3807b8dceac29ac1f6cc49ccca6d8a9bb067f93f839703e0c
4
- data.tar.gz: 5a72d84147fc1f149695fe0244a11e7f99f2c9b4debb0b99a291e16439b873ac
3
+ metadata.gz: 32e4043eb57f025e3a834a364be09cae1e35fa401bbf95bac88351272297d4f9
4
+ data.tar.gz: bb88b34edd232839b1b760a2bcb834bd25f8a244d3ad02c4a91097f14d85b377
5
5
  SHA512:
6
- metadata.gz: 2354c67d6866dd82b70172279fd47a65bced5a950ce4385df97a1d3ab3373724d7f49a2ee7185e9cca3f86f403892e732afeade705c74337d7ae32ed53815632
7
- data.tar.gz: 851104267914cd47c8312723af3361a13e5ee5fa2a323f73add55a0a09e04d965492f870c66ffc81f24c5851239afd35485b367633192c80ce8b3d8d90a7753a
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` taking `current_user` (plus the controller's `params` if it takes a second argument) that returns the relation to list, overriding the default `policy_scope`. The user is resolved via `SimpleCrud::Config.user_method` (`:current_user` by default; set it to e.g. `:current_admin`)
119
+ - Scope: only valid for `:index`. A `Proc`/`lambda` that runs with the controller as `self` (so `@ivars` and route-parent state are available) and returns the relation to list, overriding the default `policy_scope`. It takes `current_user` (plus the controller's `params` when it takes a second argument); an arity-0 lambda gets no arguments. The user is resolved via `SimpleCrud::Config.user_method` (`:current_user` by default; set it to e.g. `:current_admin`)
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 the `scope:` option, a callable that receives the user resolved via `SimpleCrud::Config.user_method` (`nil` when there is none, and `params` too when it takes a second argument):
216
+ When `authorize: true`, the `:index` action paginates the Pundit `policy_scope` of the model (falling back to the full relation when no `Scope` is defined) instead of `klass.all`, so "only my records" scoping works out of the box. Override the scope per action with `scope:`: a callable that receives the user resolved via `SimpleCrud::Config.user_method` (`nil` when there is none, and `params` too when it takes a second argument), or a `Symbol` naming a class method on the model so query composition lives on the model:
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 either an [ActiveModelSerializers](https://github.com/rails-api/active_model_serializers) serializer class, which simple_crud builds per record with `serializer.new(record, serializer_options)`, or a [Blueprinter](https://github.com/blueprinter/blueprinter) blueprint class, which it renders with `blueprint.render_as_hash(record, serializer_options)`.
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
- klass.find(controller.params[:id])
60
+ finder_scope.find(controller.params[:id])
61
61
  end
62
62
 
63
63
  def build_record
64
- parameters[:build] ? controller.instance_exec(&parameters[:build]) : klass.new
64
+ return controller.instance_exec(&parameters[:build]) if parameters[:build]
65
+ return relation_source.relation!.build if relation_source.configured?
66
+
67
+ klass.new
68
+ end
69
+
70
+ # Falls back to klass when owned_by:/parent: is unset.
71
+ def finder_scope
72
+ return klass unless relation_source.configured?
73
+
74
+ relation_source.relation!
75
+ end
76
+
77
+ def relation_source
78
+ @relation_source ||= SimpleCrud::RelationSource.new(controller, klass, parameters)
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
- call_scope
51
- elsif parameters[:authorize]
52
- SimpleCrud::Config.authorization_adapter.policy_scope(controller, klass)
53
- else
54
- klass.all
55
- end
49
+ return call_scope if parameters[:scope]
50
+ return relation_source.relation || klass.none if relation_source.configured?
51
+
52
+ default_index_relation
53
+ end
54
+
55
+ def default_index_relation
56
+ return SimpleCrud::Config.authorization_adapter.policy_scope(controller, klass) if parameters[:authorize]
57
+
58
+ klass.all
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
- scope.arity == 1 ? scope.call(user) : scope.call(user, controller.params)
63
+ return klass.send(scope, relation_source.user, controller.params) unless scope.respond_to?(:call)
64
+
65
+ controller.instance_exec(*scope_arguments(scope), &scope)
66
+ end
67
+
68
+ def scope_arguments(scope)
69
+ case scope.arity
70
+ when 0 then []
71
+ when 1 then [relation_source.user]
72
+ else [relation_source.user, controller.params]
73
+ end
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
- if saved
23
- controller.redirect_to(redirect_target(record, options[:redirect]))
24
- else
25
- controller.render(options[:failure_template])
26
- end
22
+ return render_html_failure(options) unless saved
23
+
24
+ set_flash(:notice, parameters[:notice])
25
+ controller.redirect_to(redirect_target(record, options[:redirect]))
26
+ end
27
+
28
+ def render_html_failure(options)
29
+ set_flash(:alert, parameters[:alert], now: true)
30
+ controller.render(options[:failure_template])
31
+ end
32
+
33
+ def set_flash(key, message, now: false)
34
+ return if message.nil? || !controller.respond_to?(:flash)
35
+
36
+ target = now ? controller.flash.now : controller.flash
37
+ target[key] = message
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
 
@@ -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
- # Renders a record with the app's serializer stack.
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 (Proc/lambda taking current_user and optional params), overrides policy_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] = parameters[:authenticate] if parameters[:authenticate_headers].nil?
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleCrud
4
- VERSION = '0.6.0'
4
+ VERSION = '0.7.0'
5
5
  end
@@ -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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScopedSymbol
4
+ class DummyModelsController < ApplicationController
5
+ include Wor::Paginate
6
+ include Pundit::Authorization
7
+ extend SimpleCrudController
8
+
9
+ simple_crud_for :index, scope: :visible_to
10
+ end
11
+ end
@@ -8,4 +8,8 @@ class DummyModel < ApplicationRecord
8
8
  def self.find_by_slug(params)
9
9
  find_by!(slug: params[:slug])
10
10
  end
11
+
12
+ def self.visible_to(user, _params)
13
+ where(user: user)
14
+ end
11
15
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Exercises the plain-class serializer path.
4
+ class DummyModelPlainSerializer
5
+ def self.render(record, **options)
6
+ { id: record.id, name: record.name, something: record.something, root: options[:root] }.compact
7
+ end
8
+ end
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe ScopedSymbol::DummyModelsController, type: :controller do
6
+ include_examples 'simple crud for index with scope'
7
+ 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.6.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-17 00:00:00.000000000 Z
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