Class: TypedFormModel::Base
- Inherits:
-
Literal::Struct
- Object
- Literal::Struct
- TypedFormModel::Base
- Extended by:
- ActiveModel::Naming
- Includes:
- ActiveModel::Conversion, ActiveModel::Validations, ActiveModel::Validations::Callbacks, Literal::Types
- Defined in:
- lib/typed_form_model/base.rb
Overview
Clean-room base class for Rails form objects.
Constant Summary collapse
- UNSUPPORTED_OPTIONS =
{ optional: "Use `_Nilable(Type)` as the prop type instead (and omit `default:` to allow nil).", allow_nil: "Use `_Nilable(Type)` as the prop type for nilability; use ActiveModel validations for form-level presence.", allow_blank: "Use ActiveModel validations (`validates :field, presence: true`) instead of `allow_blank:`.", in: "Use ActiveModel validations (`validates :field, inclusion: { in: [...] }`) instead of `in:` on the prop." }.freeze
Instance Attribute Summary collapse
-
#context ⇒ Object
readonly
Returns the value of attribute context.
-
#provided_keys ⇒ Object
readonly
Frozen Set of prop names that were explicitly provided when this form was built (via
from_params,from_model(s),new, orcopy).
Class Method Summary collapse
- .boolean_type?(type) ⇒ Boolean
-
.form_name ⇒ Object
The name of the form, used by form builders.
-
.from_model(record, persisted: true, context: {}, props: nil) ⇒ Object
Build a form instance from a single source record.
-
.from_models(sources = nil, persisted: true, context: {}, props: nil, **kwargs) ⇒ Object
Build a form instance from multiple source records, keyed by the source segment of each prop's dotted
from:(e.g.from: "user.role_id"binds tosources[:user]). -
.from_params(raw, persisted: false, extract: false) ⇒ Object
Build a form instance from raw controller params.
-
.inherited(subclass) ⇒ Object
Eagerly initialise per-class registries when a subclass is defined.
-
.keys_for_permit ⇒ Object
Returns the strong-params whitelist for this form.
-
.new(**kwargs) ⇒ Object
Override .new to capture persisted/context kwargs before they reach Literal and to apply per-prop
transform:procs against the context. -
.prop(name, type, **options, &coercer) ⇒ Object
Declare a prop.
-
.prop_metadata_for(name) ⇒ Object
Reader for per-prop metadata.
-
.register_coercer(type, &block) ⇒ Object
Register a custom type coercer on this form class.
-
.validates_nested(*attr_names) ⇒ Object
Cascade validation: parent invalid if any named nested-form prop (single or array) is invalid.
Instance Method Summary collapse
- #==(other) ⇒ Object (also: #eql?)
- #[](key) ⇒ Object
- #as_json(options = {}) ⇒ Object
-
#attributes(include_id: false) ⇒ Object
Returns a HashWithIndifferentAccess of non-nil attrs.
- #cache_key ⇒ Object
-
#copy(**overrides) ⇒ Object
Copy the form with attribute overrides.
- #hash ⇒ Object
-
#merge(other) ⇒ Object
Merge another form of the same class on top of this one.
- #persisted? ⇒ Boolean
-
#to_hash ⇒ Object
(also: #to_h)
Returns a plain Hash (symbol keys) of all attrs incl.
-
#to_model_attributes(model_name, except: []) ⇒ Object
Extract attributes whose
from:source segment matchesmodel_name. - #to_params ⇒ Object
Instance Attribute Details
#context ⇒ Object (readonly)
Returns the value of attribute context.
447 448 449 |
# File 'lib/typed_form_model/base.rb', line 447 def context @context end |
#provided_keys ⇒ Object (readonly)
Frozen Set of prop names that were explicitly provided when this form
was built (via from_params, from_model(s), new, or copy).
Distinct from "non-nil" — a key set to nil is still provided. Used by
merge to layer PATCH semantics correctly.
457 458 459 |
# File 'lib/typed_form_model/base.rb', line 457 def provided_keys @provided_keys end |
Class Method Details
.boolean_type?(type) ⇒ Boolean
198 199 200 201 |
# File 'lib/typed_form_model/base.rb', line 198 def boolean_type?(type) type == _Boolean || (type.is_a?(::Literal::Types::NilableType) && type.type == _Boolean) end |
.form_name ⇒ Object
The name of the form, used by form builders. Falls back to param_key.
94 95 96 |
# File 'lib/typed_form_model/base.rb', line 94 def form_name model_name.param_key end |
.from_model(record, persisted: true, context: {}, props: nil) ⇒ Object
Build a form instance from a single source record. Every prop is read
from record using its from: source-attribute (or its own name when
from: is omitted). The source segment of a dotted from: is ignored
in this single-source path. props: (Array of Symbols) restricts which
props are pulled.
137 138 139 |
# File 'lib/typed_form_model/base.rb', line 137 def from_model(record, persisted: true, context: {}, props: nil) ModelLoader.new(self).from_model(record, persisted: persisted, context: context, props: props) end |
.from_models(sources = nil, persisted: true, context: {}, props: nil, **kwargs) ⇒ Object
Build a form instance from multiple source records, keyed by the source
segment of each prop's dotted from: (e.g. from: "user.role_id" binds
to sources[:user]). Props without a dotted from: are skipped — there
is no implicit fallback when the caller provides multiple sources.
Foo.from_models(user: user, profile: profile)
Foo.from_models(user, profile: profile, context: ...)
props: (Array of Symbols) restricts which props are pulled across
sources.
149 150 151 152 |
# File 'lib/typed_form_model/base.rb', line 149 def from_models(sources = nil, persisted: true, context: {}, props: nil, **kwargs) sources = kwargs if sources.nil? && kwargs.any? ModelLoader.new(self).from_models(sources || {}, persisted: persisted, context: context, props: props) end |
.from_params(raw, persisted: false, extract: false) ⇒ Object
Build a form instance from raw controller params.
Accepts Hash, ActionController::Parameters, or nil.
extract: true unwraps the params from under the form's param_key
(params[form_name]) and permits them to keys_for_permit — the
common controller entry point.
110 111 112 113 |
# File 'lib/typed_form_model/base.rb', line 110 def from_params(raw, persisted: false, extract: false) raw = extract_form_params(raw) if extract new(**ParamsLoader.new(self).call(raw, persisted: persisted)) end |
.inherited(subclass) ⇒ Object
Eagerly initialise per-class registries when a subclass is defined.
Lazy ||= initialisation is unsafe if two threads concurrently look up
coercer_registry / prop_metadata_registry on a freshly-defined
subclass — each thread would build its own registry, one wins
assignment, registrations on the loser are silently lost.
31 32 33 34 35 |
# File 'lib/typed_form_model/base.rb', line 31 def self.inherited(subclass) super subclass.instance_variable_set(:@coercer_registry, coercer_registry.child) subclass.instance_variable_set(:@prop_metadata_registry, .dup) end |
.keys_for_permit ⇒ Object
Returns the strong-params whitelist for this form.
128 129 130 |
# File 'lib/typed_form_model/base.rb', line 128 def keys_for_permit StrongParamsBuilder.new(self).call end |
.new(**kwargs) ⇒ Object
Override .new to capture persisted/context kwargs before they reach Literal
and to apply per-prop transform: procs against the context.
__skip_transforms only takes effect when the SKIP_TRANSFORMS sentinel
is passed (used internally by copy); any other value (including true)
is treated as a no-op so external callers can't bypass transforms.
__provided_keys carries an explicit Set of provided prop names from
copy (which knows the union of self + overrides). It's stripped from
kwargs before reaching Literal. External callers cannot use it: any
value is overwritten by kwargs.keys.to_set unless copy passes one.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/typed_form_model/base.rb', line 60 def new(**kwargs) skip_transforms = kwargs.delete(:__skip_transforms).equal?(SKIP_TRANSFORMS) explicit_provided = kwargs.delete(:__provided_keys) # Honour `__provided_keys` only when `copy` is also bypassing transforms. # External callers may set `__provided_keys` directly but it's ignored # unless the SKIP_TRANSFORMS sentinel was paired with it. explicit_provided = nil unless skip_transforms && explicit_provided.is_a?(::Set) persisted = kwargs.delete(:persisted) || false context = kwargs.delete(:context) || {} provided_keys = (explicit_provided || kwargs.keys.to_set).freeze kwargs = apply_transforms(kwargs, context) unless skip_transforms instance = super instance.instance_variable_set(:@persisted, persisted) instance.instance_variable_set(:@context, context) instance.instance_variable_set(:@provided_keys, provided_keys) instance end |
.prop(name, type, **options, &coercer) ⇒ Object
Declare a prop. Props without a default are implicitly nilable with
default nil. Exception: array-shaped types (Array, _Array(T),
_Array(NestedForm)) without a default: auto-default to [] and
are NOT made nilable — empty collections are the natural zero value.
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/typed_form_model/base.rb', line 158 def prop(name, type, **, &coercer) (name, ) = (name, ) blank_to_nil_opt = .delete(:blank_to_nil) apply_blank_to_nil = blank_to_nil_opt != false && blank_to_nil_applicable?(type) coercer ||= default_coercer_for(type) coercer = wrap_with_blank_to_nil(coercer) if apply_blank_to_nil # Predicates run on the user's declared type, not the auto-rewrapped # one. Without this, `prop :flag, _Nilable(_Boolean)` becomes # `_Nilable(_Nilable(_Boolean))` below and `boolean_type?` misses; # likewise `_Nilable(NestedForm)` would lose its `*_attributes=` setter. declared_type = type unless .key?(:default) if array_shaped_type?(type) [:default] = -> { [] } else type = _Nilable(type) [:default] = -> {} end end super(name, type, **, &coercer) define_method(:"#{name}?") { !!send(name) } if boolean_type?(declared_type) && !method_defined?(:"#{name}?") define_nested_attributes_setter(name) if nested_form_capable?(declared_type) (name, ) end |
.prop_metadata_for(name) ⇒ Object
Reader for per-prop metadata. Walks ancestry so subclasses inherit.
190 191 192 |
# File 'lib/typed_form_model/base.rb', line 190 def (name) [name] end |
.register_coercer(type, &block) ⇒ Object
Register a custom type coercer on this form class. register_coercer(Money) { |v| v.is_a?(Money) ? v : Money.parse(v) }
47 48 49 |
# File 'lib/typed_form_model/base.rb', line 47 def register_coercer(type, &block) coercer_registry.register(type, &block) end |
.validates_nested(*attr_names) ⇒ Object
Cascade validation: parent invalid if any named nested-form prop (single or array) is invalid. Propagates each child error to the parent under the parent attribute path, using the child's full_message.
101 102 103 |
# File 'lib/typed_form_model/base.rb', line 101 def validates_nested(*attr_names) validates_with(::TypedFormModel::NestedValidator, attributes: attr_names) end |
Instance Method Details
#==(other) ⇒ Object Also known as: eql?
494 495 496 |
# File 'lib/typed_form_model/base.rb', line 494 def ==(other) other.class == self.class && other.to_hash == to_hash end |
#[](key) ⇒ Object
460 461 462 |
# File 'lib/typed_form_model/base.rb', line 460 def [](key) send(key.to_sym) end |
#as_json(options = {}) ⇒ Object
503 504 505 |
# File 'lib/typed_form_model/base.rb', line 503 def as_json( = {}) to_hash.as_json() end |
#attributes(include_id: false) ⇒ Object
Returns a HashWithIndifferentAccess of non-nil attrs. Excludes :id by default (forms don't own IDs).
466 467 468 469 470 471 472 473 |
# File 'lib/typed_form_model/base.rb', line 466 def attributes(include_id: false) result = self.class.literal_properties.each_with_object({}) do |prop, memo| value = send(prop.name) memo[prop.name] = value unless value.nil? end result.delete(:id) unless include_id ActiveSupport::HashWithIndifferentAccess.new(result) end |
#cache_key ⇒ Object
507 508 509 510 511 512 513 |
# File 'lib/typed_form_model/base.rb', line 507 def cache_key data = attributes return data.cache_key_with_version if data.respond_to?(:cache_key_with_version) return data.cache_key if data.respond_to?(:cache_key) return Digest::SHA1.hexdigest(data) if data.is_a?(::String) Digest::SHA1.hexdigest(Marshal.dump(data)) end |
#copy(**overrides) ⇒ Object
Copy the form with attribute overrides. Validation errors are reset
on the copy — re-validate if you need error state preserved.
Transforms are NOT re-applied to the copied values (they already ran
when the original was built). Overrides are passed through as-is.
provided_keys on the copy = self.provided_keys ∪ overrides.keys.
520 521 522 523 524 525 526 527 528 529 |
# File 'lib/typed_form_model/base.rb', line 520 def copy(**overrides) merged_provided = (@provided_keys || ::Set.new) | overrides.keys.to_set self.class.new( __skip_transforms: self.class.const_get(:SKIP_TRANSFORMS), __provided_keys: merged_provided, persisted: @persisted, context: @context, **to_hash.merge(overrides) ) end |
#hash ⇒ Object
499 500 501 |
# File 'lib/typed_form_model/base.rb', line 499 def hash [self.class, to_hash].hash end |
#merge(other) ⇒ Object
Merge another form of the same class on top of this one. Keys that
other explicitly provided (via provided_keys) override self,
including explicit nils — so PATCH callers can un-set a field by
passing it as nil. Keys absent from other.provided_keys leave
self's value untouched.
536 537 538 539 540 541 542 |
# File 'lib/typed_form_model/base.rb', line 536 def merge(other) unless other.is_a?(self.class) raise ArgumentError, "Cannot merge #{other.class} into #{self.class}" end overrides = other.to_hash.select { |k, _| other.provided_keys.include?(k) } copy(**overrides) end |
#persisted? ⇒ Boolean
449 450 451 |
# File 'lib/typed_form_model/base.rb', line 449 def persisted? @persisted end |
#to_hash ⇒ Object Also known as: to_h
Returns a plain Hash (symbol keys) of all attrs incl. nils.
476 477 478 479 480 |
# File 'lib/typed_form_model/base.rb', line 476 def to_hash self.class.literal_properties.each_with_object({}) do |prop, memo| memo[prop.name] = send(prop.name) end end |
#to_model_attributes(model_name, except: []) ⇒ Object
Extract attributes whose from: source segment matches model_name.
Returns HashWithIndifferentAccess. Skips nil values. except: removes
keys AFTER the from: attribute-segment rename has been applied.
490 491 492 |
# File 'lib/typed_form_model/base.rb', line 490 def to_model_attributes(model_name, except: []) ModelAttributesExtractor.new(self).call(model_name, except: except) end |
#to_params ⇒ Object
483 484 485 |
# File 'lib/typed_form_model/base.rb', line 483 def to_params {self.class.form_name => attributes.to_h} end |