pennycress 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 481d0ac59ec75712e97bd7ddf0bd6a3dc712e616056d544ca015bfd84fd439f1
4
+ data.tar.gz: 102c76801dbfb8b2ae6cc11a83acee5405ef8f8277d8d51037f225999dcec2e4
5
+ SHA512:
6
+ metadata.gz: c2e852b9413643f112c51425b593e550fac26130865b526c96982621b185d031586c14e4a5f42bf92442ae25c0dcd0a8628209d15df5afd9a4f8ab2a6278c529
7
+ data.tar.gz: 2a7243f6ed832c3ff8434db21e4723070529007ce26c6445b7be86d3f5f9b22b748e377cc72ac676dc9118765ba17e491c6435dd05676c4dde5b49e14da4f932
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Justin Locsei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,333 @@
1
+ <p align="center">
2
+ <img src="./assets/logo.svg" width="600" alt="Pennycress">
3
+ </p>
4
+
5
+ <br>
6
+
7
+ <p align="center">
8
+ Pennycress is a Rails gem that allows you to describe expensive, model-based computations as Ruby classes. You define a list of input models, a way to transform those models into a cached output value, and any model changes that should invalidate it. Values can also provide a plan for warming the cache, enabling distributed, batch-focused precomputation. By capturing the full lifecycle of a cached value in Ruby, Pennycress offers a lightweight alternative to materialized views that lets you fix hot read paths without recreating them in SQL.
9
+ </p>
10
+
11
+ ---
12
+
13
+ [![Verify](https://github.com/justinlocsei/pennycress/actions/workflows/verify.yml/badge.svg)](https://github.com/justinlocsei/pennycress/actions/workflows/verify.yml)
14
+ [![Gem Version](https://img.shields.io/gem/v/pennycress.svg)](https://rubygems.org/gems/pennycress)
15
+ [![License](https://img.shields.io/github/license/justinlocsei/pennycress.svg)](https://github.com/justinlocsei/pennycress/blob/main/LICENSE)
16
+
17
+ <!-- <toc> -->
18
+ - [Installation](#installation)
19
+ - [Quick Start](#quick-start)
20
+ - [Rails Integration](#rails-integration)
21
+ - [How Values Work](#how-values-work)
22
+ - [Inputs](#inputs)
23
+ - [Outputs](#outputs)
24
+ - [Fetching a Value](#fetching-a-value)
25
+ - [Fetching Multiple Values](#fetching-multiple-values)
26
+ - [Cache Invalidation](#cache-invalidation)
27
+ - [Producing Invalidation Inputs](#producing-invalidation-inputs)
28
+ - [Specifying Invalidation Triggers](#specifying-invalidation-triggers)
29
+ - [Handling Destroyed Records](#handling-destroyed-records)
30
+ - [Cache Warming](#cache-warming)
31
+ - [Warming the Cache](#warming-the-cache)
32
+ - [Batch Computation](#batch-computation)
33
+ - [Configuring Pennycress](#configuring-pennycress)
34
+ - [Why the Name?](#why-the-name)
35
+ <!-- </toc> -->
36
+
37
+ ## Installation
38
+
39
+ Add Pennycress to your application's `Gemfile`:
40
+
41
+ ```ruby
42
+ gem "pennycress"
43
+ ```
44
+
45
+ Then install it:
46
+
47
+ ```sh
48
+ bundle install
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ Create the following file at `app/values/account_order_total.rb`:
54
+
55
+ ```ruby
56
+ class AccountOrderTotal < Pennycress::Value
57
+ # Derive the order total from the Account model
58
+ input :account
59
+
60
+ # Compute and cache a numeric order total
61
+ output Integer
62
+
63
+ # Evict the cached value for a changed account
64
+ watch :account do |account|
65
+ [{ account: account }]
66
+ end
67
+
68
+ # Evict the cached value for the account associated with a changed order
69
+ watch :order do |order|
70
+ [{ account: order.account_id }]
71
+ end
72
+
73
+ # Run a cache-warming job for every active organization
74
+ seeds { Organization.active.ids }
75
+
76
+ # Use a model method to calculate the order total
77
+ #
78
+ # In this example, we're assuming that this is an expensive call that triggers
79
+ # lots of reads. Maybe it's legacy code that hasn't been touched in ten years.
80
+ def compute(account:)
81
+ account.calculate_completed_order_total
82
+ end
83
+
84
+ # Warm the cache for each account in an organization
85
+ #
86
+ # This runs in the context of a background job. Each value emitted by `seeds`
87
+ # enqueues one background job, so allowing multiple warming jobs to run at
88
+ # once can rapidly populate cached values.
89
+ def seed_to_inputs(organization_id)
90
+ Organization
91
+ .find(organization_id)
92
+ .active_accounts
93
+ .map { |account| { account: account } }
94
+ end
95
+ end
96
+ ```
97
+
98
+ Any application code that needs the order total for an account can request it by passing an `Account` instance:
99
+
100
+ ```ruby
101
+ AccountOrderTotal.fetch(account: account)
102
+ ```
103
+
104
+ ## Rails Integration
105
+
106
+ Any class that subclasses `Pennycress::Value` and lives under `app/values` is loaded automatically when your Rails app boots or reloads. Values can be placed in nested directories, allowing for paths like `app/values/billing/account_order_total.rb`.
107
+
108
+ All discovered values have [invalidation handlers](#cache-invalidation) registered and are made available for [cache warming](#cache-warming). To use a different location for your value definitions, see [Configuring Pennycress](#configuring-pennycress) for details.
109
+
110
+ ## How Values Work
111
+
112
+ A Pennycress `Value` describes the transformation of a set of model inputs into an output with a stable cache key. The smallest useful value specifies its input, output, and a `#compute` method that can delegate to the application's existing logic:
113
+
114
+ ```ruby
115
+ class AccountOrderTotal < Pennycress::Value
116
+ input :account
117
+ output Integer
118
+
119
+ def compute(account:)
120
+ account.calculate_completed_order_total
121
+ end
122
+ end
123
+ ```
124
+
125
+ The `input` and `output` declarations act as both documentation and validation. If a caller provides an invalid account input, or `#compute` returns a non-numeric value, `Pennycress::ValidationError` is raised.
126
+
127
+ ### Inputs
128
+
129
+ The `input` declaration accepts one or more model identifiers:
130
+
131
+ ```ruby
132
+ input :account
133
+ input :account, :organization
134
+ ```
135
+
136
+ A model identifier follows Rails naming conventions, with `:account` resolving to `Account` and `:uploaded_file` mapping to `UploadedFile`. The resolved class must be a database-backed model that inherits from `ActiveRecord::Base`.
137
+
138
+ ### Outputs
139
+
140
+ The `output` declaration describes the type of data a value produces from its model inputs, which can be either a Ruby class or a hash with multiple typed fields:
141
+
142
+ ```ruby
143
+ output Integer
144
+ output count: Integer, labels: Array
145
+ ```
146
+
147
+ A hash output validates the type of each key's value and raises an error when unknown or missing keys are detected. Pennycress performs shallow validation of each field against its declared type, so deep validation of an array's items or child hashes should be handled by the caller of `.fetch`.
148
+
149
+ ### Fetching a Value
150
+
151
+ Application code requests a computed value by calling `.fetch`. This validates the model inputs, computes the value in the case of a cache miss, and verifies the type of the resulting output.
152
+
153
+ When requesting a computed value, callers can provide either a persisted model instance or its primary key:
154
+
155
+ ```ruby
156
+ AccountOrderTotal.fetch(account: account)
157
+ AccountOrderTotal.fetch(account: 1)
158
+ ```
159
+
160
+ Pennycress uses the primary key when constructing the cache key, so these forms are equivalent. On a cache hit, passing a primary key does not load the model from the database. Composite primary keys are supported by passing their key values as an array.
161
+
162
+ ### Fetching Multiple Values
163
+
164
+ When application code needs a computed value for several models, it should use `.fetch_many`:
165
+
166
+ ```ruby
167
+ totals = AccountOrderTotal.fetch_many([
168
+ { account: alfa },
169
+ { account: bravo },
170
+ { account: charlie }
171
+ ])
172
+ ```
173
+
174
+ The returned values match the order of the inputs, with the second item in `totals` containing the value computed for account `bravo`. Cached outputs are reused, and only misses are computed.
175
+
176
+ By default, Pennycress calls `#compute` once for each miss. Values can override `#compute_many` to replace those calls with a more efficient batch calculation, which is explored in depth in [Batch Computation](#batch-computation).
177
+
178
+ ## Cache Invalidation
179
+
180
+ Pennycress enables fine-grained invalidation in response to committed changes to Active Record models. A value declares invalidation rules using `watch`, which specifies a model and logic to run when an instance of that model changes:
181
+
182
+ ```ruby
183
+ class AccountOrderTotal < Pennycress::Value
184
+ watch :account do |account|
185
+ [{ account: account }]
186
+ end
187
+ end
188
+ ```
189
+
190
+ The symbol passed to `watch` resolves to an Active Record class using Rails naming conventions. When a matching model is created, updated, or destroyed, Pennycress calls the block with the changed record and evicts the cached output for every input returned by the block. With an input's value removed from the cache, the next call to `.fetch` will calculate it with the latest model data.
191
+
192
+ ### Producing Invalidation Inputs
193
+
194
+ A watch block returns an enumerable of inputs for the value. It may return one input, several inputs, or an empty array when a change does not affect the value. Inputs follow the same rules used for the values passed to `.fetch`, supporting either model instances or IDs:
195
+
196
+ ```ruby
197
+ watch :account { |account| [{ account: account }] }
198
+ watch :order { |order| [{ account: order.account_id }] }
199
+ ```
200
+
201
+ Inputs can be any valid `Enumerable`, rather than just a concrete `Array`. If your list of inputs may be large, you can use a lazy or custom `Enumerator` to reduce memory usage during invalidation.
202
+
203
+ ### Specifying Invalidation Triggers
204
+
205
+ By default, a watch responds to `:create`, `:update`, and `:destroy` commits. If you would like to only respond to a subset of those events, use `on:` to narrow the actions that trigger invalidation:
206
+
207
+ ```ruby
208
+ watch :order, on: %i[create update] do |order|
209
+ [{ account: order.account_id }]
210
+ end
211
+ ```
212
+
213
+ If a value should only be invalidated when specific fields change, the watch block can inspect Active Record's saved-change information and return an empty list of inputs for unrelated changes:
214
+
215
+ ```ruby
216
+ watch :account, on: [:update] do |account|
217
+ account.saved_change_to_billing_status? ? [{ account: account }] : []
218
+ end
219
+ ```
220
+
221
+ ### Handling Destroyed Records
222
+
223
+ Destroy invalidations run after the destroy transaction is committed. The record's in-memory attributes, including its primary and foreign keys, are generally still available, but the row will no longer exists in the database.
224
+
225
+ These conditions can result in errors when trying to fetch associated records. For watches that respond to a `:destroy` commit, derive inputs from attributes retained on the destroyed instance:
226
+
227
+ ```ruby
228
+ watch :order, on: %i[create update destroy] do |order|
229
+ [{ account: order.account_id }]
230
+ end
231
+ ```
232
+
233
+ If a watch cannot handle destroyed records using the same logic as created or updated ones, you can define separate invalidation handlers for each action:
234
+
235
+ ```ruby
236
+ watch :order, on: %i[create update] do |order|
237
+ [{ account: order.account.id }]
238
+ end
239
+
240
+ watch :order, on: [:destroy] do |order|
241
+ [{ account: order.account_id }]
242
+ end
243
+ ```
244
+
245
+ ## Cache Warming
246
+
247
+ Pennycress values can define seeds that divide the work of cache warming into discrete background jobs. Each seed returned from the `seeds` block creates an Active Job that passes its seed to `.seed_to_inputs`, and the resulting inputs are fetched through the normal cache pipeline.
248
+
249
+ ```ruby
250
+ class AccountOrderTotal < Pennycress::Value
251
+ input :account
252
+ output Integer
253
+
254
+ seeds { Organization.active.ids }
255
+
256
+ def seed_to_inputs(organization_id)
257
+ Organization
258
+ .find(organization_id)
259
+ .active_accounts
260
+ .map { |account| { account: account } }
261
+ end
262
+
263
+ def compute(account:)
264
+ account.calculate_completed_order_total
265
+ end
266
+ end
267
+ ```
268
+
269
+ Seeds can be any value that can be safely serialized in the context of an Active Job. When possible, prefer primitive values like numbers and strings.
270
+
271
+ Seeding is a performance optimization to eagerly populate the cache for values that are known to put pressure on the database. A value can define as narrow or wide a set of inputs for warming as are appropriate to the computed value.
272
+
273
+ ### Warming the Cache
274
+
275
+ All registered `Value` classes that define seeds can be warmed via the `pennycress:warm_cache` Rake task, making it a natural fit for post-deployment tasks. If you prefer to start cache warming through Ruby code, you can call `Pennycress.warm_cache`.
276
+
277
+ Both operations will block while they enqueue Active Jobs for each warming seed. This should be a fast operation, since the computation of each value is performed in the background jobs, and the only synchronous work is the calculation of seeds.
278
+
279
+ Warming jobs will be added to the default queue without any further configuration. If you wish to use a named queue to limit concurrent jobs, you can use the `warming_queue` option detailed in [Configuring Pennycress](#configuring-pennycress).
280
+
281
+ ## Batch Computation
282
+
283
+ The default `#compute_many` implementation calls `#compute` for each input that resulted in a cache miss. If a value can be calculated more efficiently in bulk, define a custom `compute_many` implementation:
284
+
285
+ ```ruby
286
+ class AccountOrderTotal < Pennycress::Value
287
+ input :account
288
+ output Integer
289
+
290
+ def compute(account:)
291
+ account.calculate_completed_order_total
292
+ end
293
+
294
+ def compute_many(inputs)
295
+ ids = inputs.map do |input|
296
+ input[:account].id
297
+ end
298
+
299
+ by_id = Account.calculate_completed_order_totals_for_ids(ids)
300
+
301
+ ids.map do |id|
302
+ by_id.fetch(id)
303
+ end
304
+ end
305
+ end
306
+ ```
307
+
308
+ This example uses a theoretical method that efficiently calculates order totals for multiple accounts and exposes the totals in a hash keyed by account ID. By fetching these values once and mapping them to the ordered inputs, `compute_many` avoids N+1 issues when calculating multiple account totals.
309
+
310
+ ## Configuring Pennycress
311
+
312
+ While Pennycress comes with sane defaults, you can customize its integration with your Rails app using an initializer. The available settings and their default values are shown below:
313
+
314
+ ```ruby
315
+ # config/initializers/pennycress.rb
316
+ Pennycress.configure do |config|
317
+ # The ActiveSupport::Cache::Store instance used for all cache operations
318
+ config.cache = Rails.cache
319
+
320
+ # The prefix used for the cache key of all Pennycress values
321
+ config.cache_namespace = "pennycress"
322
+
323
+ # The directories in which values can be defined
324
+ config.directories = ["app/values"]
325
+
326
+ # The queue to which warming jobs are added
327
+ config.warming_queue = :default
328
+ end
329
+ ```
330
+
331
+ ## Why the Name?
332
+
333
+ In [floristry](https://www.instagram.com/justinlocsei/), pennycress is a filler flower with lots of tiny leaves. Fine, granular foliage feels appropriate for a library that offers fine-grained cache invalidation.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennycress
4
+ # A cache is a thin wrapper around an ActiveSupport cache store that uses
5
+ # specialized output references for all cache operations.
6
+ class Cache
7
+ WRITE_OPTIONS = { skip_nil: true }.freeze
8
+ private_constant :WRITE_OPTIONS
9
+
10
+ # Creates a cache
11
+ #
12
+ # @param store [ActiveSupport::Cache::Store] the backing cache store
13
+ def initialize(store)
14
+ @store = store
15
+ end
16
+
17
+ # Evicts a cached value
18
+ #
19
+ # @param reference [OutputReference]
20
+ # @return [void]
21
+ def evict(reference)
22
+ @store.delete(reference.cache_key)
23
+ end
24
+
25
+ # Evicts cached values
26
+ #
27
+ # @param references [Array<OutputReference>]
28
+ # @return [void]
29
+ def evict_many(references)
30
+ @store.delete_multi(references.map(&:cache_key))
31
+ end
32
+
33
+ # Reads a cached value, computing and storing it when absent
34
+ #
35
+ # @param reference [OutputReference]
36
+ # @yieldparam reference [OutputReference] a reference that was not cached
37
+ # @yieldreturn [Object] the value to cache in the case of a cache miss
38
+ # @return [Object] the cached or computed value
39
+ def fetch(reference, &)
40
+ @store.fetch(reference.cache_key, **WRITE_OPTIONS, &)
41
+ end
42
+
43
+ # Reads a set of cached values, computing and storing misses
44
+ #
45
+ # @param references [Array<OutputReference>]
46
+ # @yieldparam reference [OutputReference] a reference that was not cached
47
+ # @yieldreturn [Object] the value to cache for the reference
48
+ # @return [Array<Object>] cached or computed values in reference order
49
+ def fetch_multi(references)
50
+ keys = references.map(&:cache_key)
51
+ refs_by_key = keys.zip(references).to_h
52
+
53
+ cached = @store.fetch_multi(*keys, **WRITE_OPTIONS) do |key|
54
+ yield refs_by_key.fetch(key)
55
+ end
56
+
57
+ keys.map { |key| cached.fetch(key) }
58
+ end
59
+
60
+ # Writes a value to the cache
61
+ #
62
+ # @param reference [OutputReference]
63
+ # @param value [Object] the value to cache
64
+ # @return [void]
65
+ def write(reference, value)
66
+ @store.write(reference.cache_key, value, **WRITE_OPTIONS)
67
+ end
68
+
69
+ # Writes multiple values to the cache
70
+ #
71
+ # @param entries [Hash{OutputReference => Object}]
72
+ # @return [void]
73
+ def write_multi(entries)
74
+ @store.write_multi(
75
+ entries.transform_keys(&:cache_key),
76
+ **WRITE_OPTIONS
77
+ )
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennycress
4
+ # This module defines helpers for building cache keys.
5
+ module Caching
6
+ # Builds a cache key from segments
7
+ #
8
+ # @param segments [Array<Object>]
9
+ # @return [String]
10
+ def self.key(*segments)
11
+ segments
12
+ .compact
13
+ .map(&:to_s)
14
+ .reject(&:empty?)
15
+ .join("/")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/cache/null_store"
4
+
5
+ module Pennycress
6
+ # The configuration stores all global settings for Pennycress.
7
+ class Configuration
8
+ class << self
9
+ # Builds a new configuration
10
+ #
11
+ # @yieldparam config [Configuration] the configuration to customize
12
+ # @return [Configuration] the built configuration
13
+ def build
14
+ config = new
15
+ yield config
16
+ config
17
+ end
18
+
19
+ # @return [Configuration] the current configuration
20
+ def current
21
+ @current ||= new
22
+ end
23
+
24
+ # Expands relative paths against a root
25
+ #
26
+ # @param root [Pathname, String] the root for relative paths
27
+ # @param paths [Array<String>] paths to expand
28
+ # @return [Array<String>] absolute paths
29
+ def expand_paths(root, paths)
30
+ paths.map do |path|
31
+ File.absolute_path?(path) ? path : File.join(root, path)
32
+ end
33
+ end
34
+
35
+ # Builds a modified copy of the current configuration
36
+ #
37
+ # @yieldparam config [Configuration] a copy of the current configuration
38
+ # @return [Configuration] the modified configuration
39
+ def modify
40
+ config = current.dup
41
+ yield config
42
+ config
43
+ end
44
+
45
+ # Runs a block with a temporary configuration
46
+ #
47
+ # @param config [Configuration] the configuration to use
48
+ # @yield run a block in which the given configuration is the current one
49
+ # @return [Object] the block's return value
50
+ def override(config)
51
+ previous = @current
52
+ @current = config
53
+
54
+ yield
55
+ ensure
56
+ @current = previous
57
+ end
58
+ end
59
+
60
+ # @!attribute [w] cache
61
+ # @param value [ActiveSupport::Cache::Store]
62
+ attr_writer :cache
63
+
64
+ # @!attribute [rw] cache_namespace
65
+ # @return [String] a global prefix for cache keys
66
+ attr_accessor :cache_namespace
67
+
68
+ # @!attribute [rw] directories
69
+ # @return [Array<String>] absolute paths to directories containing value classes
70
+ attr_accessor :directories
71
+
72
+ # @!attribute [rw] warming_queue
73
+ # @return [Symbol] the queue for the warming job
74
+ attr_accessor :warming_queue
75
+
76
+ # Creates a configuration
77
+ def initialize
78
+ @cache_namespace = "pennycress"
79
+ @directories = []
80
+ @warming_queue = :default
81
+ end
82
+
83
+ # @return [ActiveSupport::Cache::Store] the cache store to use
84
+ def cache
85
+ @cache ||= ActiveSupport::Cache::NullStore.new
86
+ end
87
+
88
+ protected
89
+
90
+ # @param original [Configuration] the configuration to copy
91
+ def initialize_copy(original)
92
+ @cache = original.instance_variable_get(:@cache)
93
+ @cache_namespace = original.cache_namespace.dup
94
+ @directories = original.directories.dup
95
+ @warming_queue = original.warming_queue
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennycress
4
+ # This module provides a collection of helpers for enforcing constraints on
5
+ # user-facing classes.
6
+ module Constraints
7
+ # Raises an error stating that the named method is required
8
+ #
9
+ # @param name [Symbol] the name of the method
10
+ # @raise [NotImplementedError]
11
+ def require_method(name)
12
+ raise NotImplementedError, "#{self.class} must implement ##{name}"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennycress
4
+ class Error < StandardError; end
5
+ class ValidationError < Error; end
6
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_reference"
4
+ require_relative "models"
5
+ require_relative "schema"
6
+
7
+ module Pennycress
8
+ # An input describes a value's model inputs.
9
+ class Input
10
+ # @return [Array<Symbol>] the IDs of the models used by the value
11
+ attr_reader :model_ids
12
+
13
+ # Creates an input schema for a value
14
+ #
15
+ # @param model_ids [Array<Symbol>] model IDs (e.g., `:uploaded_file, :user`)
16
+ # @raise [ArgumentError] if model IDs are missing or invalid
17
+ def initialize(*model_ids)
18
+ raise ArgumentError, "model IDs are required" if model_ids.empty?
19
+ raise ArgumentError, "model IDs must be symbols" unless model_ids.all?(Symbol)
20
+
21
+ @model_ids = model_ids.uniq.sort
22
+ end
23
+
24
+ # Validates an input against the schema
25
+ #
26
+ # @param input [Object] user-provided input
27
+ # @return [Hash] an input that conforms to the schema
28
+ # @raise [ValidationError] if the input is invalid
29
+ def validate(input)
30
+ Schema.validate_shape(schema, input) do |key, value|
31
+ ModelReference.from(schema[key], value, label: key.to_s)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # @return [Hash{Symbol => Class}] a mapping of IDs to model classes
38
+ def schema
39
+ @schema ||= model_ids.to_h do |id|
40
+ [id, Pennycress::Models.resolve(id)]
41
+ end
42
+ end
43
+ end
44
+ end