wor-simple_crud 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbcb6881c359fa3b5c4ce518871a4f378d92e0698ab7d57dc489315f2f25b80b
4
- data.tar.gz: 62cf8cbd6df845cd23a388e9b2a1f8ebfdae784ff91f4e57268688bce0c0bf87
3
+ metadata.gz: b5b50548416811b034a75f72c96d4e94c41cdb9883d7112cdf720ad3febaeb11
4
+ data.tar.gz: 99ae1562bae38c2fa8ff1d697859f8643e2b818aaeb66f2e14220171fa9f28dc
5
5
  SHA512:
6
- metadata.gz: 4406326eab40876599274e927df02c9c3470d3f753dcd5f0f150b67a7b3087e2a965ec0fd18ad1e007bada514954f9a665d573d18da98bf7c93aee3ffebc1637
7
- data.tar.gz: 1deddc698fb6008531d59e5656675086a309dbc26177b403c023e7743463425626b8e977785f6ef2cbe122d4f251ad697944d1c71f665e498666feecedbae792
6
+ metadata.gz: ef7dd6acb64f882534e9951a9adf2d8deab27f2264451cb1dee2613c78e4842e38d10a638a2ed9ec623984d0d571aee558e2a3626f7bf8f016eca65dc3174ae3
7
+ data.tar.gz: 43a9b8fb46febb90d101814a33d48049ba0a5c2df3a3b14568c5423816ebd55325966fabe8673320ef3ed89b1a7bfe0ac10f4940c16b0e412649b100dd08e051
data/.rubocop.yml CHANGED
@@ -1,6 +1,10 @@
1
1
  plugins:
2
2
  - rubocop-rspec
3
3
 
4
+ inherit_mode:
5
+ merge:
6
+ - Exclude
7
+
4
8
  AllCops:
5
9
  TargetRubyVersion: 3.2
6
10
  NewCops: disable
@@ -14,3 +18,14 @@ RSpec/SpecFilePathFormat:
14
18
  RSpec/SpecFilePathSuffix:
15
19
  Exclude:
16
20
  - spec/dummy_controller_spec.rb
21
+
22
+ # Context class names are self-documenting; comments would be noise.
23
+ Style/Documentation:
24
+ Exclude:
25
+ - lib/simple_crud/*_context.rb
26
+
27
+ # The destroy failure spec must stub find_record on the instance to intercept
28
+ # both default (klass.find) and custom-finder (lambda) lookup paths.
29
+ RSpec/AnyInstance:
30
+ Exclude:
31
+ - lib/spec/shared_examples/simple_crud_for_destroy.rb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## Change log
2
2
 
3
+ ### V0.5.0
4
+
5
+ New options:
6
+ * `cache: { key:, ttl: }` on `simple_crud_for :show` and `:index` — skips the DB on cache hits. On a miss: find/query, authorize, run block, cache, render. `key` is a lambda receiving `params` or a plain string; both default when omitted (`"#{model}:#{action}:v1:#{request.fullpath}"` and 300 s). Block must return the payload hash, not call `render`. `@record`/`@records` is set before the block, so shared examples work unchanged. Controllers get a default `fetch_cached(key, ttl, &block)` backed by `Rails.cache`; override to use a different store.
7
+ * `expire_simple_crud_cache(action, path: request.fullpath)` on controllers — deletes the default cache key for an action without constructing it by hand. Only works for the default key format; custom `key:` lambdas must be deleted directly.
8
+
3
9
  ### V0.4.0
4
10
 
5
11
  Breaking:
data/README.md CHANGED
@@ -18,6 +18,7 @@ SimpleCrud
18
18
  - [Serializer](#serializer)
19
19
  - [HTML](#html)
20
20
  - [Finder](#finder)
21
+ - [Cache](#cache)
21
22
  - [Controller-level defaults](#controller-level-defaults)
22
23
  - [Shared examples](#shared-examples)
23
24
  - [Contributing](#contributing)
@@ -343,6 +344,58 @@ simple_crud_for :destroy, finder: ->(params) { current_user.models.find(params[:
343
344
 
344
345
  When omitted it defaults to `klass.find(params[:id])`, and `not_found` is still returned whenever the finder finds no record.
345
346
 
347
+ #### Cache
348
+ Pass `cache: { key:, ttl: }` on `:show` or `:index` to skip the DB on cache hits.
349
+
350
+ `key` is a lambda receiving `params`, or a plain string. Both `key` and `ttl` are optional — defaults are `"#{model}:#{action}:v1:#{request.fullpath}"` and 300 seconds. With `cache:` the block must return the payload hash, not call `render`. `@record`/`@records` is set before the block, so shared examples work unchanged.
351
+
352
+ Controllers get a default `fetch_cached(key, ttl, &block)` backed by `Rails.cache`. Override to use a different store:
353
+
354
+ ```ruby
355
+ # Override with a direct Redis connection
356
+ def fetch_cached(key, ttl)
357
+ cached = redis.get(key)
358
+ return JSON.parse(cached, symbolize_names: true) if cached
359
+
360
+ result = yield
361
+ redis.setex(key, ttl, result.to_json)
362
+ result
363
+ end
364
+
365
+ # All options explicit
366
+ simple_crud_for :show,
367
+ finder: ->(p) { Article.includes(:author).find(p[:id]) },
368
+ cache: { key: ->(p) { "article:v1:#{p[:id]}" }, ttl: 900 } do |article|
369
+ article_payload(article)
370
+ end
371
+
372
+ # All defaults — key and TTL inferred from model name and params
373
+ simple_crud_for :index, paginate: false, cache: {} do |articles|
374
+ articles.map { |a| article_summary(a) }
375
+ end
376
+ ```
377
+
378
+ Invalidate on write. For the default key format, `expire_simple_crud_cache(action)` deletes it without constructing it by hand:
379
+
380
+ ```ruby
381
+ expire_simple_crud_cache(:show)
382
+ expire_simple_crud_cache(:index)
383
+ ```
384
+
385
+ For a custom `key:` lambda, delete the key yourself:
386
+
387
+ ```ruby
388
+ def update
389
+ article = current_user.articles.find(params[:id])
390
+ if article.update(article_params)
391
+ redis.del("article:v1:#{article.id}")
392
+ render json: article_payload(article)
393
+ else
394
+ render json: { errors: article.errors.full_messages }, status: :unprocessable_entity
395
+ end
396
+ end
397
+ ```
398
+
346
399
  ### Shared examples
347
400
  While optional, using the included shared examples saves you from writing the standard test cases for the methods. You can even use them if you didn't use `simple_crud_for`, as a set of basic tests. To include them, add `require 'simple_crud/rspec'` to your `rails_helper.rb` **after** `require "rspec/rails"`, then add the lines you need to your `*_spec.rb` files:
348
401
  ```ruby
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ class ActionContext
5
+ DEFAULT_CACHE_TTL = 300
6
+
7
+ attr_reader :controller, :klass, :parameters, :block
8
+
9
+ def self.cache_key_for(model, action, path)
10
+ "#{model.model_name.singular}:#{action}:v1:#{path}"
11
+ end
12
+
13
+ def initialize(controller, klass, parameters, &block)
14
+ @controller = controller
15
+ @klass = klass
16
+ @parameters = parameters
17
+ @block = block
18
+ end
19
+
20
+ def call
21
+ controller.authenticate_user! if parameters[:authenticate]
22
+ run
23
+ end
24
+
25
+ private
26
+
27
+ def run
28
+ raise NotImplementedError, "#{self.class} must implement run"
29
+ end
30
+
31
+ def action_name
32
+ raise NotImplementedError, "#{self.class} must implement action_name to support caching"
33
+ end
34
+
35
+ def maybe_authorize(object)
36
+ return unless parameters[:authorize]
37
+
38
+ SimpleCrud::Config.authorization_adapter.authorize(controller, object)
39
+ end
40
+
41
+ def find_record
42
+ record = lookup_record
43
+ raise ActiveRecord::RecordNotFound, "couldn't find #{klass}" if record.nil?
44
+ unless record.is_a?(ActiveRecord::Base)
45
+ raise ActiveRecord::RecordNotFound, "#{klass} finder must return a single record"
46
+ end
47
+
48
+ record
49
+ end
50
+
51
+ def lookup_record
52
+ finder = parameters[:finder]
53
+ return klass.find(controller.params[:id]) if finder.nil?
54
+ return klass.send(finder, controller.params) unless finder.respond_to?(:call)
55
+
56
+ finder.call(controller.params)
57
+ end
58
+
59
+ def build_record
60
+ parameters[:build] ? controller.instance_exec(&parameters[:build]) : klass.new
61
+ end
62
+
63
+ def permitted_params
64
+ controller.send("#{klass.model_name.singular}_params")
65
+ end
66
+
67
+ def serialize_opts(key)
68
+ { key => parameters[:serializer] }.compact
69
+ end
70
+
71
+ def render_record(record, template)
72
+ if parameters[:html] || block
73
+ controller.instance_variable_set(:@record, record)
74
+ block ? controller.instance_exec(record, &block) : controller.render(template)
75
+ else
76
+ controller.render({ json: record }.merge(serialize_opts(:serializer)))
77
+ end
78
+ end
79
+
80
+ def render_cached(cache_opts, &fetch_block)
81
+ key = resolve_cache_key(cache_opts)
82
+ ttl = cache_opts[:ttl] || DEFAULT_CACHE_TTL
83
+ result = controller.send(:fetch_cached, key, ttl, &fetch_block)
84
+ controller.render json: result
85
+ end
86
+
87
+ def resolve_cache_key(cache_opts)
88
+ k = cache_opts[:key]
89
+ return default_cache_key if k.nil?
90
+
91
+ k.respond_to?(:call) ? controller.instance_exec(controller.params, &k) : k
92
+ end
93
+
94
+ def default_cache_key
95
+ self.class.cache_key_for(klass, action_name, controller.request.fullpath)
96
+ end
97
+ end
98
+ end
@@ -3,78 +3,10 @@
3
3
  module SimpleCrud
4
4
  # Builds the lambda installed as each action declared with simple_crud_for.
5
5
  module ActionLambdas
6
- def crud_lambda_for_show(klass, parameters = {}, &block)
7
- lambda do
8
- authenticate_user! if parameters[:authenticate]
9
- requested = SimpleCrudController.find_record(klass, self, parameters)
10
-
11
- options = {}.merge(serializer: parameters[:serializer]).compact
12
- SimpleCrudController.maybe_authorize(self, requested, parameters)
13
- SimpleCrudController.render_show(self, requested, options, parameters, &block)
14
- end
15
- end
16
-
17
- def crud_lambda_for_new(klass, parameters = {}, &block)
18
- lambda do
19
- authenticate_user! if parameters[:authenticate]
20
- record = SimpleCrudController.build_record(self, klass, parameters)
21
- SimpleCrudController.maybe_authorize(self, record, parameters)
22
- SimpleCrudController.render_new(self, record, parameters, &block)
23
- end
24
- end
25
-
26
- # The find-instead-of-build twin of :new.
27
- def crud_lambda_for_edit(klass, parameters = {}, &block)
28
- lambda do
29
- authenticate_user! if parameters[:authenticate]
30
- requested = SimpleCrudController.find_record(klass, self, parameters)
31
- SimpleCrudController.maybe_authorize(self, requested, parameters)
32
- SimpleCrudController.render_edit(self, requested, parameters, &block)
33
- end
34
- end
35
-
36
- def crud_lambda_for_index(klass, parameters = {}, &block)
37
- lambda do
38
- authenticate_user! if parameters[:authenticate]
39
- SimpleCrudController.maybe_authorize(self, klass, parameters)
40
- options = {}.merge(each_serializer: parameters[:serializer]).compact
41
- SimpleCrudController.render_index(self, klass, options, parameters, &block)
42
- end
43
- end
44
-
45
- def crud_lambda_for_create(klass, parameters = {}, &block)
46
- lambda do
47
- authenticate_user! if parameters[:authenticate]
48
- permitted_params = send("#{self.class.simple_crud_controller_model.to_s.underscore}_params")
49
- record = SimpleCrudController.build_record(self, klass, parameters)
50
- record.assign_attributes(permitted_params)
51
- SimpleCrudController.maybe_authorize(self, record, parameters)
52
- persist = ->(bang:) { bang ? record.save! : record.save }
53
- options = { status: :created, failure_template: :new }
54
- SimpleCrudController.save_and_render(self, record, parameters, options, persist, &block)
55
- end
56
- end
57
-
58
- def crud_lambda_for_update(klass, parameters = {}, &block)
59
- lambda do
60
- authenticate_user! if parameters[:authenticate]
61
- requested = SimpleCrudController.find_record(klass, self, parameters)
62
- SimpleCrudController.maybe_authorize(self, requested, parameters)
63
- permitted_params = send("#{self.class.simple_crud_controller_model.to_s.underscore}_params")
64
- persist = ->(bang:) { bang ? requested.update!(permitted_params) : requested.update(permitted_params) }
65
- options = { status: :ok, failure_template: :edit }
66
- SimpleCrudController.save_and_render(self, requested, parameters, options, persist, &block)
67
- end
68
- end
69
-
70
- def crud_lambda_for_destroy(klass, parameters = {}, &block)
71
- lambda do
72
- authenticate_user! if parameters[:authenticate]
73
- requested = SimpleCrudController.find_record(klass, self, parameters)
74
- SimpleCrudController.maybe_authorize(self, requested, parameters)
75
- options = { status: :ok, failure_template: :show, redirect: parameters[:redirect] || klass }
76
- persist = ->(bang:) { bang ? requested.destroy! : requested.destroy }
77
- SimpleCrudController.persist_and_render(self, requested, parameters, options, persist, &block)
6
+ %i[show new edit index create update destroy].each do |action|
7
+ ctx_class = SimpleCrud.const_get(:"#{action.to_s.capitalize}Context")
8
+ define_method(:"crud_lambda_for_#{action}") do |klass, parameters = {}, &block|
9
+ -> { ctx_class.new(self, klass, parameters, &block).call }
78
10
  end
79
11
  end
80
12
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ # Default fetch_cached included into controllers that extend SimpleCrudController.
5
+ # Override in the controller to use a different store.
6
+ module CacheHelpers
7
+ def fetch_cached(key, ttl, &block)
8
+ Rails.cache.fetch(key, expires_in: ttl, &block)
9
+ end
10
+
11
+ def expire_simple_crud_cache(action, path: request.fullpath)
12
+ Rails.cache.delete(
13
+ SimpleCrud::ActionContext.cache_key_for(self.class.simple_crud_controller_model, action, path)
14
+ )
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ class CreateContext < PersistenceContext
5
+ private
6
+
7
+ def run
8
+ attrs = permitted_params
9
+ record = build_record
10
+ record.assign_attributes(attrs)
11
+ maybe_authorize(record)
12
+ persist = ->(bang:) { bang ? record.save! : record.save }
13
+ options = { status: :created, failure_template: :new, redirect: parameters[:redirect] || record }
14
+ persist_and_render(record, options, persist)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ class DestroyContext < PersistenceContext
5
+ private
6
+
7
+ def run
8
+ record = find_record
9
+ maybe_authorize(record)
10
+ persist = ->(bang:) { bang ? record.destroy! : record.destroy }
11
+ options = { status: :ok, failure_template: :show, redirect: parameters[:redirect] || klass }
12
+ persist_and_render(record, options, persist)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ class EditContext < ActionContext
5
+ private
6
+
7
+ def run
8
+ record = find_record
9
+ maybe_authorize(record)
10
+ render_record(record, :edit)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ class IndexContext < ActionContext
5
+ private
6
+
7
+ def action_name = :index
8
+
9
+ def run
10
+ maybe_authorize(klass)
11
+ cache_opts = parameters[:cache]
12
+ cache_opts ? render_cached(cache_opts) { payload } : render_index
13
+ end
14
+
15
+ def payload
16
+ opts = serialize_opts(:each_serializer)
17
+ relation = index_relation
18
+ records = index_records(relation, opts)
19
+ controller.instance_variable_set(:@records, records)
20
+ block ? controller.instance_exec(records, &block) : records.as_json
21
+ end
22
+
23
+ def render_index
24
+ relation = index_relation
25
+ opts = serialize_opts(:each_serializer)
26
+ if parameters[:html] || block
27
+ render_records(relation, opts)
28
+ elsif parameters[:paginate]
29
+ SimpleCrud::Config.pagination_adapter.paginate(controller, relation, opts)
30
+ else
31
+ controller.render({ json: relation }.merge(opts))
32
+ end
33
+ end
34
+
35
+ def render_records(relation, opts)
36
+ records = index_records(relation, opts)
37
+ controller.instance_variable_set(:@records, records)
38
+ block ? controller.instance_exec(records, &block) : controller.render(:index)
39
+ end
40
+
41
+ def index_relation
42
+ if parameters[:scope]
43
+ call_scope
44
+ elsif parameters[:authorize]
45
+ SimpleCrud::Config.authorization_adapter.policy_scope(controller, klass)
46
+ else
47
+ klass.all
48
+ end
49
+ end
50
+
51
+ def call_scope
52
+ user_method = SimpleCrud::Config.user_method
53
+ user = controller.respond_to?(user_method) ? controller.public_send(user_method) : nil
54
+ scope = parameters[:scope]
55
+ scope.arity == 1 ? scope.call(user) : scope.call(user, controller.params)
56
+ end
57
+
58
+ def index_records(relation, opts)
59
+ return relation unless parameters[:paginate]
60
+
61
+ SimpleCrud::Config.pagination_adapter.paginated_records(controller, relation, opts)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ class NewContext < ActionContext
5
+ private
6
+
7
+ def run
8
+ record = build_record
9
+ maybe_authorize(record)
10
+ render_record(record, :new)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ class PersistenceContext < ActionContext
5
+ private
6
+
7
+ def persist_and_render(record, options, persist)
8
+ saved = parameters[:raise_on_invalid] ? persist.call(bang: true) : persist.call(bang: false)
9
+ return render_persisted(record, saved, options) unless block || parameters[:html]
10
+
11
+ controller.instance_variable_set(:@record, record)
12
+ block ? controller.instance_exec(record, saved, &block) : render_html_redirect(record, saved, options)
13
+ end
14
+
15
+ def render_html_redirect(record, saved, options)
16
+ if saved
17
+ controller.redirect_to(redirect_target(record, options[:redirect]))
18
+ else
19
+ controller.render(options[:failure_template])
20
+ end
21
+ end
22
+
23
+ def redirect_target(record, target)
24
+ target.is_a?(Proc) ? controller.instance_exec(record, &target) : target
25
+ end
26
+
27
+ def render_persisted(record, saved, options)
28
+ return controller.render(json: record, status: options[:status]) if saved
29
+
30
+ controller.render json: { errors: record.errors.full_messages }, status: 422
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ class ShowContext < ActionContext
5
+ private
6
+
7
+ def action_name = :show
8
+
9
+ def run
10
+ cache_opts = parameters[:cache]
11
+ if cache_opts
12
+ render_cached(cache_opts) { payload }
13
+ else
14
+ record = find_record
15
+ maybe_authorize(record)
16
+ render_record(record, :show)
17
+ end
18
+ end
19
+
20
+ def payload
21
+ record = find_record
22
+ maybe_authorize(record)
23
+ controller.instance_variable_set(:@record, record)
24
+ block ? controller.instance_exec(record, &block) : record.as_json
25
+ end
26
+ end
27
+ end
@@ -2,15 +2,26 @@
2
2
 
3
3
  require 'active_support/all'
4
4
  require_relative 'config'
5
- require_relative 'controller_helpers'
5
+ require_relative 'cache_helpers'
6
+ require_relative 'action_context'
7
+ require_relative 'persistence_context'
8
+ require_relative 'show_context'
9
+ require_relative 'index_context'
10
+ require_relative 'new_context'
11
+ require_relative 'edit_context'
12
+ require_relative 'create_context'
13
+ require_relative 'update_context'
14
+ require_relative 'destroy_context'
6
15
  require_relative 'action_lambdas'
7
16
 
8
17
  # Extended onto a controller for CRUD actions.
9
18
  module SimpleCrudController
10
- extend SimpleCrud::ControllerHelpers
11
- # Include, not extend: controllers gain these by extending SimpleCrudController.
12
19
  include SimpleCrud::ActionLambdas
13
20
 
21
+ def self.extended(base)
22
+ base.include(SimpleCrud::CacheHelpers)
23
+ end
24
+
14
25
  # Possible options:
15
26
  ### authorize: check authorization via Config.authorization_adapter (Pundit by default)
16
27
  ### paginate: paginate the list via Config.pagination_adapter (wor-paginate by default)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ class UpdateContext < PersistenceContext
5
+ private
6
+
7
+ def run
8
+ record = find_record
9
+ maybe_authorize(record)
10
+ attrs = permitted_params
11
+ persist = ->(bang:) { bang ? record.update!(attrs) : record.update(attrs) }
12
+ options = { status: :ok, failure_template: :edit, redirect: parameters[:redirect] || record }
13
+ persist_and_render(record, options, persist)
14
+ end
15
+ end
16
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleCrud
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -52,7 +52,7 @@ RSpec.shared_examples 'simple crud for destroy' do
52
52
  before do
53
53
  model
54
54
  allow(model).to receive(:destroy).and_return(false)
55
- allow(SimpleCrudController).to receive(:find_record).and_return(model)
55
+ allow_any_instance_of(SimpleCrud::DestroyContext).to receive(:find_record).and_return(model)
56
56
  delete :destroy, params: with_route_params(record_param(:destroy, model)), format: request_format(:destroy)
57
57
  end
58
58
 
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Cached::DummyModelsController, type: :controller do
6
+ before { Rails.cache.clear }
7
+
8
+ include_examples 'simple crud for show with finder'
9
+
10
+ describe 'GET #show' do
11
+ let!(:record) { create(:dummy_model) }
12
+
13
+ it 'serves from cache on repeated requests' do
14
+ get :show, params: { slug: record.slug }
15
+ first_body = response.parsed_body
16
+
17
+ record.update!(name: 'updated')
18
+ get :show, params: { slug: record.slug }
19
+
20
+ expect(response.parsed_body).to eq(first_body)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe CachedDefaults::DummyModelsController, type: :controller do
6
+ before { Rails.cache.clear }
7
+
8
+ include_examples 'simple crud for show'
9
+
10
+ describe 'GET #index' do
11
+ before { create_list(:dummy_model, 2) }
12
+
13
+ it 'returns all records' do
14
+ get :index
15
+ expect(response.parsed_body.length).to eq(2)
16
+ end
17
+ end
18
+
19
+ describe 'expire_simple_crud_cache' do
20
+ let!(:record) { create(:dummy_model) }
21
+
22
+ context 'when called after a cached show' do
23
+ before { get :show, params: { id: record.id } }
24
+
25
+ it 'clears the entry so the next request fetches fresh data' do
26
+ first_body = response.parsed_body
27
+ record.update!(name: 'updated')
28
+ controller.expire_simple_crud_cache(:show)
29
+ get :show, params: { id: record.id }
30
+ expect(response.parsed_body).not_to eq(first_body)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cached
4
+ class DummyModelsController < ApplicationController
5
+ extend SimpleCrudController
6
+ simple_crud_defaults authorize: false, authenticate: false
7
+
8
+ simple_crud_for :show,
9
+ finder: ->(p) { DummyModel.find_by!(slug: p[:slug]) },
10
+ cache: { key: ->(p) { "dm:#{p[:slug]}" }, ttl: 60 } do |record|
11
+ { cached_id: record.id }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CachedDefaults
4
+ class DummyModelsController < ApplicationController
5
+ extend SimpleCrudController
6
+ simple_crud_defaults authorize: false, authenticate: false
7
+
8
+ simple_crud_for :show, cache: {}
9
+ simple_crud_for :index, paginate: false, cache: {}
10
+ end
11
+ end
@@ -4,6 +4,14 @@ Rails.application.routes.draw do
4
4
  devise_for :users
5
5
  resources :dummy_models
6
6
 
7
+ namespace :cached do
8
+ resources :dummy_models, only: :show, param: :slug
9
+ end
10
+
11
+ namespace :cached_defaults do
12
+ resources :dummy_models, only: %i[show index]
13
+ end
14
+
7
15
  namespace :without_pagination do
8
16
  resources :dummy_models, only: :index
9
17
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe SimpleCrud::ActionContext do
6
+ let(:ctx) { described_class.new(double, double, {}) }
7
+
8
+ it 'raises NotImplementedError when run is not overridden' do
9
+ expect { ctx.send(:run) }.to raise_error(NotImplementedError)
10
+ end
11
+
12
+ it 'raises NotImplementedError when action_name is not overridden' do
13
+ expect { ctx.send(:action_name) }.to raise_error(NotImplementedError)
14
+ end
15
+
16
+ describe '.cache_key_for' do
17
+ it 'builds the standard cache key' do
18
+ key = described_class.cache_key_for(DummyModel, :show, '/dummy_models/1')
19
+ expect(key).to eq('dummy_model:show:v1:/dummy_models/1')
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe SimpleCrud::IndexContext do
6
+ describe '#call_scope' do
7
+ after { SimpleCrud::Config.user_method = :current_user }
8
+
9
+ let(:controller) { double(params: { status: 'active' }) }
10
+
11
+ it 'passes nil when controller has no current_user' do
12
+ scope = ->(u) { u ? :scoped : :unscoped }
13
+ ctx = described_class.new(controller, DummyModel, { scope: scope, authorize: false })
14
+ expect(ctx.send(:call_scope)).to eq(:unscoped)
15
+ end
16
+
17
+ it 'passes params as second arg when scope has arity 2' do
18
+ scope = ->(u, p) { [u, p[:status]] }
19
+ ctx = described_class.new(controller, DummyModel, { scope: scope, authorize: false })
20
+ expect(ctx.send(:call_scope)).to eq([nil, 'active'])
21
+ end
22
+
23
+ it 'resolves user via overridden Config.user_method' do
24
+ SimpleCrud::Config.user_method = :current_admin
25
+ admin = double
26
+ ctrl = double(params: {}, current_admin: admin)
27
+ ctx = described_class.new(ctrl, DummyModel, { scope: ->(u) { u }, authorize: false })
28
+ expect(ctx.send(:call_scope)).to eq(admin)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe SimpleCrud::ShowContext do
6
+ describe '#find_record' do
7
+ it 'raises when a custom finder returns something other than a record' do
8
+ finder = ->(_params) { DummyModel.where(name: 'x') }
9
+ ctx = described_class.new(double(params: {}), DummyModel, { finder: finder, authorize: false })
10
+ expect { ctx.send(:find_record) }
11
+ .to raise_error(ActiveRecord::RecordNotFound, /must return a single record/)
12
+ end
13
+ end
14
+ end
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.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - icoluccio
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-30 00:00:00.000000000 Z
10
+ date: 2026-09-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -48,18 +48,25 @@ files:
48
48
  - README.md
49
49
  - Rakefile
50
50
  - lib/simple_crud.rb
51
+ - lib/simple_crud/action_context.rb
51
52
  - lib/simple_crud/action_lambdas.rb
52
53
  - lib/simple_crud/authorization/action_policy_adapter.rb
53
54
  - lib/simple_crud/authorization/adapter.rb
54
55
  - lib/simple_crud/authorization/can_can_can_adapter.rb
55
56
  - lib/simple_crud/authorization/pundit_adapter.rb
57
+ - lib/simple_crud/cache_helpers.rb
56
58
  - lib/simple_crud/config.rb
57
- - lib/simple_crud/controller_helpers.rb
59
+ - lib/simple_crud/create_context.rb
60
+ - lib/simple_crud/destroy_context.rb
61
+ - lib/simple_crud/edit_context.rb
62
+ - lib/simple_crud/index_context.rb
63
+ - lib/simple_crud/new_context.rb
58
64
  - lib/simple_crud/pagination/adapter.rb
59
65
  - lib/simple_crud/pagination/kaminari_adapter.rb
60
66
  - lib/simple_crud/pagination/pagy_adapter.rb
61
67
  - lib/simple_crud/pagination/will_paginate_adapter.rb
62
68
  - lib/simple_crud/pagination/wor_paginate_adapter.rb
69
+ - lib/simple_crud/persistence_context.rb
63
70
  - lib/simple_crud/rspec.rb
64
71
  - lib/simple_crud/rspec/config.rb
65
72
  - lib/simple_crud/rspec/helpers.rb
@@ -69,7 +76,9 @@ files:
69
76
  - lib/simple_crud/rspec/helpers/policies.rb
70
77
  - lib/simple_crud/rspec/helpers/requests.rb
71
78
  - lib/simple_crud/rspec/helpers/settings.rb
79
+ - lib/simple_crud/show_context.rb
72
80
  - lib/simple_crud/simple_crud_controller.rb
81
+ - lib/simple_crud/update_context.rb
73
82
  - lib/simple_crud/version.rb
74
83
  - lib/spec/matchers/have_been_serialized_with.rb
75
84
  - lib/spec/response_helper.rb
@@ -108,6 +117,8 @@ files:
108
117
  - spec/block_redirect/dummy_models_controller_spec.rb
109
118
  - spec/block_show/dummy_models_controller_spec.rb
110
119
  - spec/built/dummy_models_controller_spec.rb
120
+ - spec/cached/dummy_models_controller_spec.rb
121
+ - spec/cached_defaults/dummy_models_controller_spec.rb
111
122
  - spec/dummy/Rakefile
112
123
  - spec/dummy/app/assets/config/manifest.js
113
124
  - spec/dummy/app/controllers/application_controller.rb
@@ -121,6 +132,8 @@ files:
121
132
  - spec/dummy/app/controllers/block_redirect/dummy_models_controller.rb
122
133
  - spec/dummy/app/controllers/block_show/dummy_models_controller.rb
123
134
  - spec/dummy/app/controllers/built/dummy_models_controller.rb
135
+ - spec/dummy/app/controllers/cached/dummy_models_controller.rb
136
+ - spec/dummy/app/controllers/cached_defaults/dummy_models_controller.rb
124
137
  - spec/dummy/app/controllers/dummy_models_controller.rb
125
138
  - spec/dummy/app/controllers/finder/dummy_models_controller.rb
126
139
  - spec/dummy/app/controllers/html/dummy_models_controller.rb
@@ -211,12 +224,14 @@ files:
211
224
  - spec/redirect_auth/dummy_models_controller_spec.rb
212
225
  - spec/scoped/dummy_models_controller_spec.rb
213
226
  - spec/scoped_params/dummy_models_controller_spec.rb
227
+ - spec/simple_crud/action_context_spec.rb
214
228
  - spec/simple_crud/authorization/action_policy_adapter_spec.rb
215
229
  - spec/simple_crud/authorization/can_can_can_adapter_spec.rb
216
230
  - spec/simple_crud/authorization/pundit_adapter_spec.rb
217
- - spec/simple_crud/controller_helpers_spec.rb
231
+ - spec/simple_crud/index_context_spec.rb
218
232
  - spec/simple_crud/pagination/adapters_spec.rb
219
233
  - spec/simple_crud/rspec_config_spec.rb
234
+ - spec/simple_crud/show_context_spec.rb
220
235
  - spec/simple_crud_configuration_spec.rb
221
236
  - spec/simple_crud_controller_spec.rb
222
237
  - spec/spec_helper.rb
@@ -1,128 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SimpleCrud
4
- # Class-level helpers shared by the CRUD lambdas defined in SimpleCrudController.
5
- module ControllerHelpers
6
- def maybe_authorize(controller, record, parameters)
7
- return unless parameters[:authorize]
8
-
9
- SimpleCrud::Config.authorization_adapter.authorize(controller, record)
10
- end
11
-
12
- def render_index(controller, klass, options, parameters, &block)
13
- relation = index_relation(controller, klass, parameters)
14
-
15
- if parameters[:html] || block
16
- records = index_records(controller, relation, options, parameters)
17
- controller.instance_variable_set(:@records, records)
18
- block ? controller.instance_exec(records, &block) : controller.render(:index)
19
- elsif parameters[:paginate]
20
- SimpleCrud::Config.pagination_adapter.paginate(controller, relation, options)
21
- else
22
- controller.render({ json: relation }.merge(options))
23
- end
24
- end
25
-
26
- def render_show(controller, record, options, parameters, &block)
27
- if parameters[:html] || block
28
- controller.instance_variable_set(:@record, record)
29
- block ? controller.instance_exec(record, &block) : controller.render(:show)
30
- else
31
- controller.render({ json: record }.merge(options))
32
- end
33
- end
34
-
35
- def render_new(controller, record, parameters, &block)
36
- render_form(controller, record, :new, parameters, &block)
37
- end
38
-
39
- def render_edit(controller, record, parameters, &block)
40
- render_form(controller, record, :edit, parameters, &block)
41
- end
42
-
43
- def render_form(controller, record, template, parameters, &block)
44
- if parameters[:html] || block
45
- controller.instance_variable_set(:@record, record)
46
- block ? controller.instance_exec(record, &block) : controller.render(template)
47
- else
48
- options = {}.merge(serializer: parameters[:serializer]).compact
49
- controller.render({ json: record }.merge(options))
50
- end
51
- end
52
-
53
- def build_record(controller, klass, parameters)
54
- parameters[:build] ? controller.instance_exec(&parameters[:build]) : klass.new
55
- end
56
-
57
- def index_relation(controller, klass, parameters)
58
- if parameters[:scope]
59
- call_scope(parameters[:scope], controller)
60
- elsif parameters[:authorize]
61
- SimpleCrud::Config.authorization_adapter.policy_scope(controller, klass)
62
- else
63
- klass.all
64
- end
65
- end
66
-
67
- def call_scope(scope, controller)
68
- user_method = SimpleCrud::Config.user_method
69
- user = controller.respond_to?(user_method) ? controller.public_send(user_method) : nil
70
- return scope.call(user) if scope.arity == 1
71
-
72
- scope.call(user, controller.params)
73
- end
74
-
75
- def index_records(controller, relation, options, parameters)
76
- return relation unless parameters[:paginate]
77
-
78
- SimpleCrud::Config.pagination_adapter.paginated_records(controller, relation, options)
79
- end
80
-
81
- def find_record(klass, controller, parameters)
82
- record = lookup_record(klass, controller, parameters)
83
- raise ActiveRecord::RecordNotFound, "couldn't find #{klass}" if record.nil?
84
- unless record.is_a?(ActiveRecord::Base)
85
- raise ActiveRecord::RecordNotFound, "#{klass} finder must return a single record"
86
- end
87
-
88
- record
89
- end
90
-
91
- def lookup_record(klass, controller, parameters)
92
- finder = parameters[:finder]
93
- return klass.find(controller.params[:id]) if finder.nil?
94
- return klass.send(finder, controller.params) unless finder.respond_to?(:call)
95
-
96
- finder.call(controller.params)
97
- end
98
-
99
- def persist_and_render(controller, record, parameters, options, persist, &block)
100
- saved = parameters[:raise_on_invalid] ? persist.call(bang: true) : persist.call(bang: false)
101
- return render_persisted(controller, record, saved, options) unless block || parameters[:html]
102
-
103
- controller.instance_variable_set(:@record, record)
104
- return controller.instance_exec(record, saved, &block) if block
105
-
106
- if saved
107
- controller.redirect_to(redirect_target(controller, record, options[:redirect]))
108
- else
109
- controller.render(options[:failure_template])
110
- end
111
- end
112
-
113
- def save_and_render(controller, record, parameters, options, persist, &block)
114
- persist_and_render(controller, record, parameters,
115
- options.merge(redirect: parameters[:redirect] || record), persist, &block)
116
- end
117
-
118
- def redirect_target(controller, record, target)
119
- target.is_a?(Proc) ? controller.instance_exec(record, &target) : target
120
- end
121
-
122
- def render_persisted(controller, record, saved, options)
123
- return controller.render(json: record, status: options[:status]) if saved
124
-
125
- controller.render json: { errors: record.errors.full_messages }, status: 422
126
- end
127
- end
128
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
- require 'simple_crud/controller_helpers'
5
-
6
- describe SimpleCrud::ControllerHelpers do
7
- subject(:helpers) do
8
- Class.new do
9
- extend SimpleCrud::ControllerHelpers
10
- end
11
- end
12
-
13
- describe '.call_scope' do
14
- after { SimpleCrud::Config.user_method = :current_user }
15
-
16
- it 'passes nil as the user when the controller has no current_user' do
17
- controller = double(params: { status: 'active' })
18
- scope = ->(user) { user ? :scoped_to_user : :unscoped }
19
-
20
- expect(helpers.call_scope(scope, controller)).to eq(:unscoped)
21
- end
22
-
23
- it 'still passes params as the second argument when the scope takes two' do
24
- controller = double(params: { status: 'active' })
25
- scope = ->(user, params) { [user, params[:status]] }
26
-
27
- expect(helpers.call_scope(scope, controller)).to eq([nil, 'active'])
28
- end
29
-
30
- it 'resolves the user via Config.user_method when overridden' do
31
- SimpleCrud::Config.user_method = :current_admin
32
- admin = double
33
- controller = double(params: {}, current_admin: admin)
34
- scope = ->(user) { user }
35
-
36
- expect(helpers.call_scope(scope, controller)).to eq(admin)
37
- end
38
- end
39
-
40
- describe '.find_record' do
41
- it 'raises when a custom finder returns something other than a record' do
42
- controller = double(params: {})
43
- parameters = { finder: ->(_params) { DummyModel.where(name: 'x') } }
44
-
45
- expect { helpers.find_record(DummyModel, controller, parameters) }
46
- .to raise_error(ActiveRecord::RecordNotFound, /must return a single record/)
47
- end
48
- end
49
- end