Class: TypedFormModel::Base

Inherits:
Literal::Struct
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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

Returns:

  • (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, **options, &coercer)
  reject_unsupported_options!(name, options)

   = capture_prop_metadata!(name, options)
  blank_to_nil_opt = options.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 options.key?(:default)
    if array_shaped_type?(type)
      options[:default] = -> { [] }
    else
      type = _Nilable(type)
      options[:default] = -> {}
    end
  end
  super(name, type, **options, &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

Indifferent access: form == form



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(options = {})
  to_hash.as_json(options)
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

Returns:

  • (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