Module: Axn::Core::ContractForSubfields

Defined in:
lib/axn/core/contract_for_subfields.rb

Defined Under Namespace

Modules: ClassMethods Classes: ResolvedSubfieldsCacheEntry

Class Method Summary collapse

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, options)
  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, options, 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, options, 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, options, 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, options, 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