Module: Axn::Core::ContractForSubfields
- Defined in:
- lib/axn/core/contract_for_subfields.rb
Defined Under Namespace
Modules: ClassMethods Classes: ResolvedSubfieldsCacheEntry
Class Method Summary collapse
-
._declared_id_token(action, configs) ⇒ Object
The effective transformed
<field>_idtoken from the DECLARED sibling routes (configs, already the priority-ordered sibling_id_configs), shared by the record lookup and the consistency check so they can never disagree about which value a present record/lookup sees. -
._memoized_raw_extract(action, config, parent) ⇒ Object
The RAW (pre-transform) extract of a config's field off its parent, memoized per-config so the wire value is read at most once — critical when the field opts into
method_call:and its reader is a non-idempotent/one-shot method: the model finder's presence probe and the field's own reader must see the SAME dispatch, not two (PRO-2910). -
.deepest_reader_index(path) ⇒ Object
The chain index of the deepest reader-bearing ancestor at-or-before the
on:target — the node resolve_parent public_sends; the hops AFTER it are the ones the runtime actually digs. - .included(base) ⇒ Object
-
.resolve_model_value(action, config, options) ⇒ Object
The model-field value read: a directly-supplied RECORD (authoritative), else a lookup by the
<field>_id— routed through that id's read-path transform when the sibling is declared, or the raw caller token when it isn't (PRO-2910) — then a record-supplying default:. -
.resolve_model_via_id(action, config, options, parent) ⇒ Object
Resolve the model record from its
<field>_id(a present RECORD is already handled by the caller, so this derives from the id ALONE). -
.resolve_parent(action, config) ⇒ Object
Resolves the parent value a subfield config is read from — CANONICALLY: through the DEEPEST reader-bearing ancestor on the chain up to the
on:target (public_sendof that reader — memoized, model-resolving, alias-aware), then raw Extract digs for any remaining implicit segments. -
.resolve_value(action, config) ⇒ Object
THE subfield value read — readers and validation share it: leaf-extract from the canonically resolved parent, then value-level default fallback (PRO-2889).
-
.sibling_id_configs(action, config) ⇒ Object
The declared sibling
<field>_idconfigs for amodel:field, in the priority order _declared_id_token reads them (for both the record lookup and the consistency check), so the two can never disagree about which route's transformed id a present record/lookup sees.
Class Method Details
._declared_id_token(action, configs) ⇒ Object
The effective transformed <field>_id token from the DECLARED sibling routes (configs, already the
priority-ordered sibling_id_configs), shared by the record lookup and the consistency check so they can
never disagree about which value a present record/lookup sees. Reads the routes in order via their own
readers (resolve_value); each route's presence probe reads the SAME memoized raw its reader consumes
(_memoized_raw_extract), so a method_call: id whose reader is a non-idempotent method dispatches at
most once (PRO-2910). Each route reads its own wire key off its own parent, so it also carries that
route's own method_call: — the id declaration governs reading the id key, not the model field's:
* a PRESENT raw id reads that route, which is AUTHORITATIVE: the first route to yield a non-nil value
wins, and a present value it resolves to nil (its preprocess maps it to nil, no own default) is
genuinely nil for this model, so we STOP rather than re-reading through another route;
* an ABSENT raw id reads ONLY defaulted routes (Schema.usable_id_token_default?) — the PRO-2889
omitted-id rescue — and skips the rest: a non-defaulted route would resolve nil anyway AND running
its reader on the absent value would fire an unguarded `preprocess:` on nil (e.g. `nil.strip`).
Returns nil when no eligible route yields a token. Callers separate the "no declared <field>_id at
all" case via sibling_id_configs.empty? (there the caller's raw token is used).
380 381 382 383 384 385 386 387 388 389 390 391 392 393 |
# File 'lib/axn/core/contract_for_subfields.rb', line 380 def self._declared_id_token(action, configs) configs.each do |sibling_config| raw = _memoized_raw_extract(action, sibling_config, resolve_parent(action, sibling_config)) # Absent id: only a defaulted route can rescue; skip the rest (they resolve nil and would run an # unguarded preprocess on the absent value). next if raw.nil? && !Axn::Internal::Reflection::Schema.usable_id_token_default?(sibling_config) value = action.public_send(sibling_config.reader_as) return value unless value.nil? # A PRESENT id this route resolves to nil is genuinely nil — don't fall through to another route. return nil unless raw.nil? end nil end |
._memoized_raw_extract(action, config, parent) ⇒ Object
The RAW (pre-transform) extract of a config's field off its parent, memoized per-config so the wire
value is read at most once — critical when the field opts into method_call: and its reader is a
non-idempotent/one-shot method: the model finder's presence probe and the field's own reader must
see the SAME dispatch, not two (PRO-2910). Only a SETTLED read memoizes; a read taken during a parent
transform is provisional (the parent may still be rewritten), so it re-extracts against the settled
parent next time — mirroring the value cache and the reader-memo drop.
195 196 197 198 199 200 201 202 203 |
# File 'lib/axn/core/contract_for_subfields.rb', line 195 def self._memoized_raw_extract(action, config, parent) memo = _raw_extract_memo(action) return memo[config] if memo.key?(config) raw = Axn::Core::FieldResolvers.extract_or_nil(field: config.field, provided_data: parent, permit_method_call: config.method_call) memo[config] = raw if _transform_in_progress_set(action).empty? raw end |
.deepest_reader_index(path) ⇒ Object
The chain index of the deepest reader-bearing ancestor at-or-before the on: target — the
node resolve_parent public_sends; the hops AFTER it are the ones the runtime actually digs.
Shared with the unanswerable-segment declaration check (SubfieldContradictions) so the two
can't disagree about which segments are dig-read. Nil when no ancestor bears a reader (the
recipe fallback path).
65 66 67 |
# File 'lib/axn/core/contract_for_subfields.rb', line 65 def self.deepest_reader_index(path) (0..path.parent_index).select { |i| _reader_config(path.ancestors[i].first) }.max end |
.included(base) ⇒ Object
17 18 19 20 21 22 23 24 |
# File 'lib/axn/core/contract_for_subfields.rb', line 17 def self.included(base) base.class_eval do # Copy-on-write, frozen at every assignment (see Contract's stores). class_attribute :subfield_configs, default: [].freeze extend ClassMethods end end |
.resolve_model_value(action, config, options) ⇒ Object
The model-field value read: a directly-supplied RECORD (authoritative), else a lookup by the
<field>_id — routed through that id's read-path transform when the sibling is declared, or the raw
caller token when it isn't (PRO-2910) — then a record-supplying default:. Non-materializing — the
parent's own value stays untouched. Used by both the InternalContext facade's top-level model reader
(depth 0) and _define_subfield_model_reader (depth ≥ 1). options is the syntactic-sugar-processed
model options for this config.
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/axn/core/contract_for_subfields.rb', line 280 def self.resolve_model_value(action, config, ) parent = resolve_parent(action, config) # Raw-read mode (enqueue-time facets): resolve the record straight from the raw parent — raw record # or a straight RAW-id lookup, no transform/rescue/default — so the facet mirrors the serialized # payload rather than a run-time-only transformed/rescued/defaulted value. return _model_from_raw_parent(config, , parent) if _raw_reads?(action) # A directly-supplied RECORD is authoritative and read raw — never overridden by an id lookup. Read # the record key through the per-config raw memo so it (and its `method_call:` dispatch) is read at # most once — shared with the model-consistency check, which reads the same key (PRO-2910). present_record = _memoized_raw_extract(action, config, parent).presence in_progress = _resolve_in_progress_set(action) # A model field's value can't be defined in terms of its own resolution: a record-supplying `default:` # OR a sibling `<field>_id` `default:` that reads this same model reader re-enters here. The re-entrant # read returns the present record or a RAW-id lookup from the parent ALONE — no transform, rescue, or # default — breaking the cycle so the Proc can complete. Mirrors resolve_value's re-entrancy guard; # the marker is set BEFORE the id lookup and the default so both re-entry routes are covered. return present_record || _model_from_raw_parent(config, , parent) if in_progress[config] # This model read is provisional only when a PARENT transform is mid-flight (keyed off the transform # set, like resolve_value) — a record resolved against an unsettled parent must be dropped and re-read. transforms = _transform_in_progress_set(action) nested = !transforms.empty? _mark_provisional_reader(action, config) if nested in_progress[config] = true begin record = present_record # The id LOOKUP reads a SIBLING `<field>_id`, whose parent is already settled — so it is NOT a # transform-in-progress (only the cycle set is marked). That lets the sibling id reader cache and # be reused by its own later validation read, running a stateful preprocess:/default: at most once # and keeping the record's id in agreement with what the `<field>_id` reader sees (PRO-2910). record ||= resolve_model_via_id(action, config, , parent) # The record-supplying `default:`, by contrast, CAN read this model's own subfields (`on:` this # field) against a record that hasn't settled yet, so it marks the transform set: those child reads # are provisional and re-resolve against the settled record once the default returns (PRO-2908). if record.nil? && config.applied_default? transforms[config] = true begin record = Axn::Internal::FieldConfig.resolve_default(action, config) ensure transforms.delete(config) end end ensure in_progress.delete(config) end # A read taken while another field is mid-resolution is provisional — return it without dropping the # provisional memos (that happens only at the outermost, settled resolve, exactly as in resolve_value). return record if nested _drop_provisional_reader_memos(action) record end |
.resolve_model_via_id(action, config, options, parent) ⇒ Object
Resolve the model record from its <field>_id (a present RECORD is already handled by the caller,
so this derives from the id ALONE). The lookup token is a DECLARED sibling <field>_id's read-path
transform (its own reader, via _declared_id_token) so the record resolves from the SAME value the
<field>_id reader, its validation, and the model-consistency check all see; with NO <field>_id
declared it is the caller's RAW token off the parent (no transform). Either way the record is looked
up through a SYNTHETIC hash keyed by the id key: the Model resolver finds no record key there, so it
goes straight to the id derivation — the caller already read the record key (present_record), and
re-reading it here would dispatch a one-shot/stateful record reader a second time (PRO-2910).
351 352 353 354 355 356 357 358 359 360 361 362 363 |
# File 'lib/axn/core/contract_for_subfields.rb', line 351 def self.resolve_model_via_id(action, config, , parent) id_key = Axn::Internal::FieldConfig.model_id_key(config.field) configs = sibling_id_configs(action, config) token = if configs.empty? Axn::Core::FieldResolvers.extract_or_nil(field: id_key, provided_data: parent, permit_method_call: config.method_call) else _declared_id_token(action, configs) end return nil if token.nil? Axn::Core::FieldResolvers.resolve(type: :model, field: config.field, options:, provided_data: { id_key => token }) end |
.resolve_parent(action, config) ⇒ Object
Resolves the parent value a subfield config is read from — CANONICALLY: through the DEEPEST
reader-bearing ancestor on the chain up to the on: target (public_send of that reader —
memoized, model-resolving, alias-aware), then raw Extract digs for any remaining implicit
segments. Both spellings of the same wire path (on: :b and on: "a.b") therefore resolve
identically: if :b is a declared subfield, its reader supplies the value either way (for a
model: subfield, the resolved record). Shared by the subfield readers and the inbound
validation runner so all consumers agree. An ambient config isn't indexed (its parent
resolves per-invocation), so it falls back to the reader-plus-digs recipe on its on: string.
Malformed hops read as absent via FieldResolvers.extract_or_nil (one doctrine: the bad
value's own validation classifies it, PRO-2857).
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/axn/core/contract_for_subfields.rb', line 36 def self.resolve_parent(action, config) path = action.class._resolved_subfields.index[config] return _resolve_parent_by_recipe(action, config.on, permit_method_call: config.method_call) if path.nil? # A top-level field is the depth-0 case: its parent IS the raw provided_data hash (no ancestor # chain to walk). Reading its leaf from here applies coerce/preprocess/default on the read path # without ever writing back — the same non-materializing model the deeper subfields use. return action.instance_variable_get(:@__context).provided_data if path.ancestors.empty? reader_index = deepest_reader_index(path) return _resolve_parent_by_recipe(action, config.on, permit_method_call: config.method_call) if reader_index.nil? value = action.public_send(_deepest_reader_name(config, path, reader_index)) (reader_index...path.parent_index).each do |i| # Every hop below the deepest reader is an IMPLICIT intermediate (a declared node bears a # reader, so it would be the reader public_sent above — never dig-crossed here). So the # resolving config's `method_call:` governs the whole dig uniformly: `method_call: true` # permits dispatch across every implicit hop on this expectation's path (PRO-2926). value = Axn::Core::FieldResolvers.extract_or_nil(field: path.ancestors[i].last.to_s, provided_data: value, permit_method_call: config.method_call) end value end |
.resolve_value(action, config) ⇒ Object
THE subfield value read — readers and validation share it: leaf-extract from the canonically resolved parent, then value-level default fallback (PRO-2889). A declared default: guarantees the RESOLVED value is never nil-by-omission even when the parent itself can't supply one (a model:/non-object parent, a parent record whose attribute is nil, a malformed parent — none of which axn can synthesize a value into). No wire data is written here and the parent's own value stays untouched, so a nil-tolerant parent remains genuinely nil.
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/axn/core/contract_for_subfields.rb', line 106 def self.resolve_value(action, config) # Memoize on the action INSTANCE, keyed by config identity — mirrors the reader memoization # that already covers configs WITH a generated reader, extending it to the reader-less callers # this seam serves (validation's no-reader branch and resolve_model_via_id's # dotted-sibling path). Without it a reader-less config re-resolves once per ActiveModel # validator, re-running a Proc default each time — a Proc default must resolve at most once per # call. A config with a reader already memoizes, so this second layer is harmless there. # `key?` presence (not truthiness) so a nil/false resolved value memoizes too. cache = if action.instance_variable_defined?(:@__resolve_value_cache) action.instance_variable_get(:@__resolve_value_cache) else action.instance_variable_set(:@__resolve_value_cache, {}.compare_by_identity) end return cache[config] if cache.key?(config) parent = resolve_parent(action, config) raw = _memoized_raw_extract(action, config, parent) # Enqueue-time facet resolution wants the raw serialized value: no coerce/preprocess/default, so a # dynamic hook runs once at perform rather than also drifting/double-executing at enqueue. return raw if _raw_reads?(action) in_progress = _resolve_in_progress_set(action) # A field's value can't be defined in terms of its own transformed result: a re-entrant read of the # SAME config (its preprocess/default reading a subfield whose parent is this very field) returns the # pre-transform extract and breaks the cycle. return raw if in_progress[config] # A read taken while ANOTHER field's read-path TRANSFORM is mid-flight is provisional — it resolves # against a parent that hasn't settled yet (e.g. a parent whose own preprocess rewrites the value its # children read), so it is returned uncached and its reader memo is dropped once the outer field # settles, so a later read re-resolves against the now-settled parent. Keyed off the transform set, # NOT the cycle set: a model id LOOKUP (resolve_model_value) marks the cycle set but not this one — # its sibling `<field>_id` read is against an already-settled parent, so it must cache (PRO-2910). transforms = _transform_in_progress_set(action) nested = !transforms.empty? _mark_provisional_reader(action, config) if nested in_progress[config] = true transforms[config] = true begin # coerce:/preprocess:/default: all resolve here, on the read path (non-materializing, value-level # — the model PRO-2889 established for subfield defaults). No wire write-back and the parent's own # value stays untouched, so axn never mutates a caller-supplied object during resolution. value = _apply_read_path_transforms(action, config, raw, parent) value = Axn::Internal::FieldConfig.resolve_default(action, config) if value.nil? && config.applied_default? ensure in_progress.delete(config) transforms.delete(config) end return value if nested cache[config] = value _drop_provisional_reader_memos(action) value end |
.sibling_id_configs(action, config) ⇒ Object
The declared sibling <field>_id configs for a model: field, in the priority order _declared_id_token
reads them (for both the record lookup and the consistency check), so the two can never disagree about
which route's transformed id a present record/lookup sees. All routes of a merged id node read the SAME
wire key, differing only in their coerce:/preprocess:/default:, so route choice is purely "which
transform interprets that one wire value":
* the id declared beside THIS model on the SAME `on:` route is AUTHORITATIVE — its transform is this
model field's canonical id (the reader user code reads for it). A present token it maps to nil is
genuinely nil for this model (_declared_id_token stops there), never re-read through an alternate route.
* the ONLY fall-through (an ABSENT id) is to a route whose default the declaration credits as a usable
token (Schema.usable_id_token_default? — sibling_id_rescued?'s predicate): the omitted-id rescue,
even when the default lives on a different route than the model. PRO-2901 forbids two defaults on one
node, so this is the node's one default.
* with neither an own-route nor a defaulted route, the sole/first declared route supplies the token
(a lone id declared on a route other than the model's).
Empty when no <field>_id is declared (the caller's raw token carries no transform) or when the
config isn't in either subfield index (an ambient config falls back to the ambient-scoped tree).
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 |
# File 'lib/axn/core/contract_for_subfields.rb', line 411 def self.sibling_id_configs(action, config) path = action.class._resolved_subfields.index[config] || action.class._ambient_subfield_tree.index[config] return [] if path.nil? id_key = Axn::Internal::FieldConfig.model_id_key(config.field) # Candidate sibling `<field>_id` configs: another top-level root at depth 0 (a declared field, not a # child of parent_node), else the children of the leaf's own wire parent. candidates = if path.ancestors.empty? action.class.internal_field_configs.select { |c| c.field == id_key } else path.parent_node.children[id_key.to_sym]&.configs || [] end own_route = candidates.find { |c| c.on.to_s == config.on.to_s } default_route = candidates.find { |c| Axn::Internal::Reflection::Schema.usable_id_token_default?(c) } # An own route or a credited default route is authoritative; the raw declaration-order fallback is # ONLY for the case where neither exists (a single undefaulted id on a non-model route), so a nil # own-route resolution never spills over into re-reading the shared wire value through another route. return [own_route, default_route].compact.uniq if own_route || default_route [candidates.first].compact end |