axn 0.1.0.pre.alpha.6 → 0.1.0.pre.alpha.6.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 +4 -4
- data/AGENTS-consuming.md +11 -2
- data/CHANGELOG.md +21 -0
- data/lib/axn/async/batch_enqueue.rb +1 -1
- data/lib/axn/core/context/facade.rb +18 -8
- data/lib/axn/core/context/facade_inspector.rb +1 -1
- data/lib/axn/core/context/internal.rb +11 -6
- data/lib/axn/core/contract/shape_declaration.rb +46 -1
- data/lib/axn/core/contract/subfield_contradictions.rb +209 -0
- data/lib/axn/core/contract.rb +349 -58
- data/lib/axn/core/contract_for_subfields.rb +51 -10
- data/lib/axn/core/executor.rb +79 -31
- data/lib/axn/core/field_resolvers/model.rb +59 -14
- data/lib/axn/core/flow/callbacks.rb +33 -13
- data/lib/axn/core/flow/fails_on.rb +48 -8
- data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +24 -0
- data/lib/axn/core/flow/handlers/invoker.rb +16 -0
- data/lib/axn/core/flow/messages.rb +12 -2
- data/lib/axn/core/hooks.rb +40 -0
- data/lib/axn/core/memoization.rb +16 -6
- data/lib/axn/core/semantic_hints.rb +13 -0
- data/lib/axn/core/validation/fields.rb +21 -1
- data/lib/axn/core/validation/validators/model_validator.rb +104 -0
- data/lib/axn/factory.rb +23 -6
- data/lib/axn/failure_origin.rb +46 -0
- data/lib/axn/internal/field_config.rb +29 -0
- data/lib/axn/internal/reflection/schema.rb +688 -41
- data/lib/axn/mountable/mounting_strategies/step.rb +10 -2
- data/lib/axn/result.rb +7 -7
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +1 -0
- metadata +7 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 259cd996e88bc18c257764dcdbf09b8329d9455a468508db610fdee30312bdd0
|
|
4
|
+
data.tar.gz: 5ad4e15519eb9a4d8af7b4ebff976c2455411e46b24073a43c8d054e78a88798
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 766cf539c8c8dde70ab669b8dbed304b1e2b8359910b0a187502971db9175e72bf5f8041db02fa60be5327fc1d212e89258c8140a6434b9de368705fc66d6292
|
|
7
|
+
data.tar.gz: f0d4cea99d2a124ec1f0dbe3f475a2ec900d4fd3221d3e3d5529f9b01bb3efe1e3fe4f629433c7de23e2a98dee7852402a4ec48799aa8672b3505f13abdb6a53
|
data/AGENTS-consuming.md
CHANGED
|
@@ -165,8 +165,17 @@ expects :user, model: true
|
|
|
165
165
|
`user_id` always means *the record's primary key*, on every path. Passing both a record and a
|
|
166
166
|
disagreeing `user_id` (default `:find` finder) raises `InboundValidationError` — contradictory
|
|
167
167
|
input is a developer error. `klass:` must be a single Class/Module (no union, no `type:`-style
|
|
168
|
-
pseudo-type); anything else raises `ArgumentError` at declaration.
|
|
169
|
-
`
|
|
168
|
+
pseudo-type); anything else raises `ArgumentError` at declaration. The `model:` bag only accepts
|
|
169
|
+
`klass:`/`finder:`/`not_found_on:`/`id_type:`/`message:` (plus the shared ActiveModel options) — a
|
|
170
|
+
typo'd key (`fnder:`, `class:` for `klass:`) raises `ArgumentError` too, rather than silently
|
|
171
|
+
falling back to the default. Source: `lib/axn/core/field_resolvers/model.rb`.
|
|
172
|
+
|
|
173
|
+
**No record found is a contract violation, not a reported exception.** A finder returning `nil` and
|
|
174
|
+
one raising its not-found error (`ActiveRecord::RecordNotFound` for `:find`) are the same outcome:
|
|
175
|
+
an `InboundValidationError` reading `User not found` — distinct from the `User can't be blank` an
|
|
176
|
+
omitted `user_id` gets. It is NOT handed to `on_ignored_exception`, so a bad id from a tool caller
|
|
177
|
+
never pages; anything else the finder raises still does. Name your own miss class with
|
|
178
|
+
`model: { finder: :fetch!, not_found_on: MyApi::NotFound }` (a `StandardError` subclass or an array of them; `[]` opts out).
|
|
170
179
|
|
|
171
180
|
**`on:` — subfields (the `:extract` resolver).** Declare expectations about nested data and get a
|
|
172
181
|
flat reader:
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.6.1
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
* [FEAT] `Axn::Failure` exposes `originating_axn_class` and `originating_result`, giving failure handlers the failing axn's class and its result (including exposures made before `fail!`) without reaching through the action instance. The readers are defined on `Axn::Failure` only and are nil when the failure was not raised by an axn.
|
|
8
|
+
* [FEAT] A `model:` field's generated `<field>_id` is typed in `.input_schema`. ActiveRecord models using the default `:find` finder infer Integer, String, or UUID from the model's primary key; `model: { id_type: Integer }`/`String`/`:uuid` provides an explicit override for POROs, custom finders, or unavailable database metadata.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
* [BREAKING] A `model:` lookup that means “not found” is now treated as an inbound validation failure, whether the finder returns `nil` or raises its configured not-found exception. This removes duplicate `best_effort`/`on_exception` noise for expected bad identifiers: required fields report one `Axn::InboundValidationError`, optional fields remain quiet, and tool-facing input errors are not reported. Other finder failures are still swallowed to `nil` and reported. `model: { finder:, not_found_on: <class or array> }` defines the not-found exception set for custom finders; an empty set opts out.
|
|
13
|
+
* [BREAKING] `Axn::Result#declared_fields` is now `#__declared_fields__`; `declared_fields`, along with `context`, `action`, `action_name`, `default_error`, and `default_success`, is available for field declarations. There is no compatibility alias for the result reader.
|
|
14
|
+
* [BREAKING] Unknown keys in `model:`, `type:`, `validate:`, and `shape:` option bags now raise at declaration instead of being silently ignored. The check applies consistently to top-level fields, subfields, shapes, `of:` positions, map axes, and `Axn::Factory.build`.
|
|
15
|
+
* [BREAKING] Explicit subfields now conjoin with an enclosing `shape:` member during schema generation, preserving the full declared shape and requiredness instead of silently replacing the member with a bare object. Model-generated `<field>_id` keys that collide with emitted nested objects are rejected at declaration.
|
|
16
|
+
* [FEAT] Validation errors rooted at `on: :ambient_context` identify that source in their rendered message, including nested and model-consistency errors, without changing structured error details.
|
|
17
|
+
* [BREAKING] The ActiveModel/ActiveSupport dependency floor is now `>= 8.1`.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
* [BUGFIX] `fails_on` now accepts multiple exception classes positionally, and `error`/`success`/`fails_on` reject invalid message values at declaration instead of misclassifying them or placing non-message objects in results.
|
|
22
|
+
* [BUGFIX] `Axn::Factory.build` correctly splats arrays for hook declarations and preserves single handlers that happen to implement `to_a`/`to_ary`; callbacks, memo declarations, and shape-block fields also support multiple entries in one call.
|
|
23
|
+
* [BUGFIX] A missing `model:` record produces one accurate validation error—“not found” when an id was supplied, “can't be blank” when it was omitted—instead of combining type and presence errors.
|
|
3
24
|
## 0.1.0-alpha.6
|
|
4
25
|
|
|
5
26
|
### Added
|
|
@@ -124,7 +124,7 @@ module Axn
|
|
|
124
124
|
# @example Count-only heartbeat via a class method
|
|
125
125
|
# on_enqueue_all :log_summary
|
|
126
126
|
# def self.log_summary(count:) = info "Found #{count} events"
|
|
127
|
-
def on_enqueue_all(
|
|
127
|
+
def on_enqueue_all(*handlers, **, &block) = _add_callback(:enqueue_all, handlers, **, block:)
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
130
|
end
|
|
@@ -26,7 +26,7 @@ module Axn
|
|
|
26
26
|
(@declared_fields + Array(implicitly_allowed_fields)).each do |field|
|
|
27
27
|
# Never define over a name the facade ITSELF answers to — its own ancestry up to Object,
|
|
28
28
|
# private methods included, since those are the ones it dispatches on itself
|
|
29
|
-
# (`
|
|
29
|
+
# (`_default_error`, `_msg_resolver`). Declarations that would land such a name are refused up
|
|
30
30
|
# front (Contract::ClassMethods#_reject_shadowed_exposure_name! and its inbound twin); this is
|
|
31
31
|
# the definition-site half of that rule, so a config reaching a facade without passing through
|
|
32
32
|
# the DSL cannot silently take a method away. Object/Kernel are deliberately NOT asked: an
|
|
@@ -38,9 +38,12 @@ module Axn
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
# Namespaced like `Axn::Result`'s `__action__`/`__exposed_keys__` rather than left as
|
|
42
|
+
# `declared_fields`: every name this class owns is one an `expects`/`exposes` declaration may not
|
|
43
|
+
# take, so the facade's own surface stays out of the namespace an author writes field names in.
|
|
44
|
+
def __declared_fields__ = @declared_fields
|
|
42
45
|
|
|
43
|
-
def inspect = ContextFacadeInspector.new(facade: self, action
|
|
46
|
+
def inspect = ContextFacadeInspector.new(facade: self, action: _action, context: _context).call
|
|
44
47
|
|
|
45
48
|
def fail!(...)
|
|
46
49
|
raise Axn::ContractViolation::MethodNotAllowed, "Call fail! directly rather than on the context"
|
|
@@ -48,7 +51,14 @@ module Axn
|
|
|
48
51
|
|
|
49
52
|
private
|
|
50
53
|
|
|
51
|
-
|
|
54
|
+
# Underscored, like everything else this class owns. The facade's method table IS the set of
|
|
55
|
+
# field names a declaration is refused (Contract::ClassMethods#_reject_shadowed_wire_key! and
|
|
56
|
+
# its exposure twin ask it by ownership), so a helper named `action` or `context` would take two
|
|
57
|
+
# ordinary domain words away from every author while offering them nothing — neither name is
|
|
58
|
+
# reachable from an action or documented anywhere. Keep new helpers here underscored;
|
|
59
|
+
# spec/axn/core/context/facade_name_surface_spec.rb enforces it.
|
|
60
|
+
def _action = @action
|
|
61
|
+
def _context = @context
|
|
52
62
|
|
|
53
63
|
# Define one field's reader. The base (outbound Result) facade reads the data source directly;
|
|
54
64
|
# InternalContext overrides this to resolve declared inbound fields through the read path.
|
|
@@ -62,9 +72,9 @@ module Axn
|
|
|
62
72
|
end
|
|
63
73
|
end
|
|
64
74
|
|
|
65
|
-
def _model_fields =
|
|
75
|
+
def _model_fields = _action.class._model_fields
|
|
66
76
|
|
|
67
|
-
def
|
|
77
|
+
def _action_name = @action.class.name.presence || "The action"
|
|
68
78
|
|
|
69
79
|
def _define_model_field_method(field, options)
|
|
70
80
|
Axn::Internal::Memoization.define_memoized_reader_method(@__singleton, field) do
|
|
@@ -81,9 +91,9 @@ module Axn
|
|
|
81
91
|
|
|
82
92
|
def _msg_resolver(event_type, exception:)
|
|
83
93
|
Axn::Core::Flow::Handlers::Resolvers::MessageResolver.new(
|
|
84
|
-
|
|
94
|
+
_action.class._messages_registry,
|
|
85
95
|
event_type,
|
|
86
|
-
action
|
|
96
|
+
action: _action,
|
|
87
97
|
exception:,
|
|
88
98
|
)
|
|
89
99
|
end
|
|
@@ -115,7 +115,7 @@ module Axn
|
|
|
115
115
|
def rendered_field_name(field) = Axn::Internal::Reflection::PropertyNames.renderable_label(field)
|
|
116
116
|
|
|
117
117
|
def class_name = facade.class.name
|
|
118
|
-
def declared_fields = facade.
|
|
118
|
+
def declared_fields = facade.__declared_fields__
|
|
119
119
|
|
|
120
120
|
def format_for_inspect(field, value)
|
|
121
121
|
return value.inspect if value.nil?
|
|
@@ -6,8 +6,13 @@ module Axn
|
|
|
6
6
|
module Core
|
|
7
7
|
# Inbound / Internal ContextFacade
|
|
8
8
|
class InternalContext < ContextFacade
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
# Underscored for the same reason as everything else on the facade (see ContextFacade's private
|
|
10
|
+
# section): the method table is the set of field names a declaration is refused. Public despite the
|
|
11
|
+
# underscore — the action-side `default_error`/`default_success` sugar dispatches these by name,
|
|
12
|
+
# and that pair stays unprefixed because Contract::InstanceMethods is surrenderable, so a field
|
|
13
|
+
# may take it and lose nothing but the helper.
|
|
14
|
+
def _default_error = _msg_resolver(:error, exception: Axn::Failure.new).resolve_default_message
|
|
15
|
+
def _default_success = _msg_resolver(:success, exception: nil).resolve_default_message
|
|
11
16
|
|
|
12
17
|
private
|
|
13
18
|
|
|
@@ -17,16 +22,16 @@ module Axn
|
|
|
17
22
|
# provided_data never mutated). A field with no config (implicitly-allowed) keeps the raw source
|
|
18
23
|
# read. Model fields resolve through the shared resolve_model_value (record + sibling-id + default).
|
|
19
24
|
def _define_reader_for(field)
|
|
20
|
-
config =
|
|
25
|
+
config = _action.class.internal_field_configs.find { |c| c.field == field }
|
|
21
26
|
return super if config.nil?
|
|
22
27
|
|
|
23
28
|
if config.validations.key?(:model)
|
|
24
29
|
Axn::Internal::Memoization.define_memoized_reader_method(@__singleton, field) do
|
|
25
|
-
Axn::Core::ContractForSubfields.resolve_model_value(
|
|
30
|
+
Axn::Core::ContractForSubfields.resolve_model_value(_action, config, config.validations[:model])
|
|
26
31
|
end
|
|
27
32
|
else
|
|
28
33
|
@__singleton.define_method(field) do
|
|
29
|
-
Axn::Core::ContractForSubfields.resolve_value(
|
|
34
|
+
Axn::Core::ContractForSubfields.resolve_value(_action, config)
|
|
30
35
|
end
|
|
31
36
|
end
|
|
32
37
|
end
|
|
@@ -36,7 +41,7 @@ module Axn
|
|
|
36
41
|
msg = <<~MSG
|
|
37
42
|
Method ##{method_name} is not available on Axn::Core::InternalContext!
|
|
38
43
|
|
|
39
|
-
#{
|
|
44
|
+
#{_action_name} may be missing a line like:
|
|
40
45
|
expects :#{method_name}
|
|
41
46
|
MSG
|
|
42
47
|
|
|
@@ -117,6 +117,7 @@ module Axn
|
|
|
117
117
|
# declared no `shape:` must not gain the key here.
|
|
118
118
|
def _snapshot_declared_shape!(validations, allowance, fields)
|
|
119
119
|
_reject_unshaped_shape!(validations, "`shape:` on #{_declared_fields_label(fields)}")
|
|
120
|
+
_reject_unknown_shape_keys!(validations, "`shape:` on #{_declared_fields_label(fields)}")
|
|
120
121
|
shape = Internal::ShapeGraph.hash_or_nil(validations[:shape])
|
|
121
122
|
return if nil.equal?(shape)
|
|
122
123
|
|
|
@@ -169,6 +170,48 @@ module Axn
|
|
|
169
170
|
"`ArgumentError: must supply :members`. Supply `shape: { members: [...] }`, or drop shape:."
|
|
170
171
|
end
|
|
171
172
|
|
|
173
|
+
# THE key-set refusal for a `shape:` node (PRO-3387), at the same four positions
|
|
174
|
+
# `_reject_unshaped_shape!` reaches: a field's own, a shape MEMBER's, an `of:` element bag's, and either
|
|
175
|
+
# axis of a map (the last two share one call site, `_snapshot_inner_shape!`). `members:`/`container:`
|
|
176
|
+
# are the only two keys `ShapeValidator` reads (`ClassMethods::SHAPE_OPTION_KEYS`) — everything else
|
|
177
|
+
# declared cleanly and constrained nothing, the same hole `_reject_unknown_bag_keys!` already closes for
|
|
178
|
+
# `of:`/`model:`/`type:`/`validate:`. `where` is the same label its `_reject_unshaped_shape!` sibling
|
|
179
|
+
# takes at every call site — passed rather than recomputed, so the two guards read the position
|
|
180
|
+
# identically and neither can name it differently from the other.
|
|
181
|
+
#
|
|
182
|
+
# Stands down on a non-Hash `shape:` — that is `_reject_unshaped_shape!`'s defect one line up, and
|
|
183
|
+
# firing here too would report a bare `ArgumentError: must supply :members` graph as also missing keys
|
|
184
|
+
# it never had a chance to name.
|
|
185
|
+
#
|
|
186
|
+
# Raises ITSELF, rather than deferring, for a shape that ANSWERS a missing key from a Hash default
|
|
187
|
+
# (`Hash.new(…)` or a `default_proc`) — `copy_entries` below is entry-wise, so a `members:`/`container:`
|
|
188
|
+
# the shape only ANSWERS is invisible to it, and every REAL entry beside it (however few) reads as
|
|
189
|
+
# unknown. This used to defer to `_walk_shape_graph!`'s own `reject_defaulting_option_container!` call,
|
|
190
|
+
# on the assumption that it always runs after this one — true when there is no block, but this guard
|
|
191
|
+
# ALSO runs ahead of a block/subblock overwrite (PRO-3387, Codex round 2, PR #275), and that overwrite
|
|
192
|
+
# replaces `validations[:shape]` before `_walk_shape_graph!` ever reaches the ORIGINAL defaulting Hash
|
|
193
|
+
# — so deferring there let a defaulting raw shape beside a block skip this check entirely, real
|
|
194
|
+
# entries included. Raising here closes that: the SAME shared helper, so the underlying defect is
|
|
195
|
+
# named identically wherever it is caught, and `_walk_shape_graph!`'s own call stays as a backstop for
|
|
196
|
+
# the one position this method is never asked about (a shape reached via `internal_field_configs=`,
|
|
197
|
+
# which skips the whole declaration walk).
|
|
198
|
+
#
|
|
199
|
+
# `:shape` is the one entry `ShapeGraph.detach_option_containers!` deliberately skips — the field's own
|
|
200
|
+
# validations bag is detached there, but the shape graph beneath it is walked and copied by this
|
|
201
|
+
# module's own machinery instead — so the Hash handed here is still the CALLER's own object. Read
|
|
202
|
+
# through `ShapeGraph.copy_entries` (the bound `each` seam) rather than a direct `.keys`, which a
|
|
203
|
+
# caller-defined Hash subclass could answer with anything: the same discipline `_symbol_keyed_bag`
|
|
204
|
+
# already reads a caller-supplied bag by.
|
|
205
|
+
def _reject_unknown_shape_keys!(carrier, where)
|
|
206
|
+
return unless Internal::ShapeGraph.carries_key?(carrier, :shape)
|
|
207
|
+
|
|
208
|
+
shape = Internal::ShapeGraph.hash_or_nil(carrier[:shape])
|
|
209
|
+
return if nil.equal?(shape)
|
|
210
|
+
|
|
211
|
+
Internal::ShapeGraph.reject_defaulting_option_container!(shape) { where }
|
|
212
|
+
_reject_unknown_bag_keys!(Internal::ShapeGraph.copy_entries(shape), ClassMethods::SHAPE_OPTION_KEYS, option: "shape:")
|
|
213
|
+
end
|
|
214
|
+
|
|
172
215
|
# THE refusal for a raw `shape:` kwarg asking to distribute — the reading PRO-3191 retires. Checked at
|
|
173
216
|
# every position a raw kwarg can occupy, ahead of the block form's own write to the same slot (a block
|
|
174
217
|
# legitimately builds a distributing shape; a raw kwarg no longer may), so `carrier` is passed rather
|
|
@@ -369,6 +412,7 @@ module Axn
|
|
|
369
412
|
# what a member's nested shape contributes to its node's height.
|
|
370
413
|
def _snapshot_inner_shape!(bag, walk, allowance, fields:, position:, via:, via_name:)
|
|
371
414
|
_reject_unshaped_shape!(bag, _inner_shape_position_label(position, via, via_name, fields))
|
|
415
|
+
_reject_unknown_shape_keys!(bag, _inner_shape_position_label(position, via, via_name, fields))
|
|
372
416
|
shape = Internal::ShapeGraph.hash_or_nil(bag[:shape])
|
|
373
417
|
return NO_INNER_CONTRACTS if nil.equal?(shape)
|
|
374
418
|
|
|
@@ -601,6 +645,7 @@ module Axn
|
|
|
601
645
|
# pre-pass, leaving no top-level `shape:` for this read to find.
|
|
602
646
|
_reject_distributing_shape!(validations, "`shape:` on shape member #{_describe_shape_member(member, name)}")
|
|
603
647
|
_reject_unshaped_shape!(validations, "`shape:` on shape member #{_describe_shape_member(member, name)}")
|
|
648
|
+
_reject_unknown_shape_keys!(validations, "`shape:` on shape member #{_describe_shape_member(member, name)}")
|
|
604
649
|
nested = Internal::ShapeGraph.hash_or_nil(validations[:shape])
|
|
605
650
|
return NO_INNER_CONTRACTS if nil.equal?(nested)
|
|
606
651
|
|
|
@@ -966,7 +1011,7 @@ module Axn
|
|
|
966
1011
|
:_raise_member_confirmation_unsupported!,
|
|
967
1012
|
:_snapshot_declared_shape!, :_validate_and_snapshot_shape!, :_walk_shape_graph!,
|
|
968
1013
|
:_distributing_shape_depth,
|
|
969
|
-
:_reject_unshaped_shape!, :_reject_distributing_shape!,
|
|
1014
|
+
:_reject_unshaped_shape!, :_reject_unknown_shape_keys!, :_reject_distributing_shape!,
|
|
970
1015
|
:_distributing_shape_message, :_distributing_container_message,
|
|
971
1016
|
:_inner_shape_position_label,
|
|
972
1017
|
:_walk_inner_contracts!, :_walk_declared_inner_contracts!, :_new_path_allowance,
|
|
@@ -33,9 +33,218 @@ module Axn
|
|
|
33
33
|
check_unanswerable_segments!(tree) # first: an unreachable path moots any ambiguity on it
|
|
34
34
|
check_subfields_under_map!(tree)
|
|
35
35
|
check_ambiguous_crossings!(tree) if crossings
|
|
36
|
+
check_model_id_object_claim!(tree, field_configs)
|
|
36
37
|
check_dead_nil_tolerance!(tree, field_configs)
|
|
37
38
|
end
|
|
38
39
|
|
|
40
|
+
# The subfield-free entry point. `check!` is skipped outright by the top-level seam when no subfield
|
|
41
|
+
# exists, on the documented grounds that with an empty tree no tolerance is unexercisable and no
|
|
42
|
+
# segment is read. That reasoning does not extend to the model-id claim, which needs no subfield at
|
|
43
|
+
# all — a top-level `<field>_id` carrying its own `shape:` block claims the key on its own — so the
|
|
44
|
+
# top-level seam routes here instead of skipping everything.
|
|
45
|
+
#
|
|
46
|
+
# Returns without building a tree when no `model:` is declared, which is what keeps the
|
|
47
|
+
# per-declaration build off the subfield-free path for every contract that cannot trip this.
|
|
48
|
+
def check_model_id_claims!(field_configs)
|
|
49
|
+
return if field_configs.none? { |c| c.validations[:model] }
|
|
50
|
+
|
|
51
|
+
tree = Axn::Internal::SubfieldTree.build(field_configs, [])
|
|
52
|
+
check_model_id_object_claim!(tree, field_configs)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The MODEL-ID OBJECT-CLAIM check (PRO-3396): a `model:` field's generated `<field>_id` names the
|
|
56
|
+
# LOOKUP TOKEN the finder consumes — a scalar. Another declaration can claim that same wire key as an
|
|
57
|
+
# object WITH CONTENTS: a dotted `on:` whose intermediate segment is spelled `<field>_id`, an explicit
|
|
58
|
+
# `<field>_id` that a subfield then nests under, or a `shape:` member of that name carrying its own
|
|
59
|
+
# `members:`. One wire key cannot be both, and the emitter reconciled neither pair — `apply_model_id_child!`
|
|
60
|
+
# recognizes a sibling only by its `configs`, so an implicit intermediate is invisible to it, while
|
|
61
|
+
# `apply_implicit_node!` does not know a model's generated id might be underneath. Whichever ran second in
|
|
62
|
+
# the insertion-ordered walk simply overwrote the other, so the surviving property followed declaration
|
|
63
|
+
# order and the loser's contribution vanished silently.
|
|
64
|
+
#
|
|
65
|
+
# Refused rather than reconciled, because there is no coherent schema to emit: the property is either the
|
|
66
|
+
# scalar the finder reads or the object the descendant is read out of. A PLAIN scalar `<field>_id` sibling
|
|
67
|
+
# is untouched — that is the supported spelling, and the one `sibling_id_configs` exists to serve.
|
|
68
|
+
#
|
|
69
|
+
# The object claim is judged by what the emitter would actually NEST, not by the claimant's declared type:
|
|
70
|
+
# `node_configs_block_nesting?` is the same predicate emission and the drop pass consult, so a node whose
|
|
71
|
+
# children are dropped (a scalar or `model:` route at the id key) raises nothing here — an unreachable
|
|
72
|
+
# segment is `check_unanswerable_segments!`'s to report, and it runs first.
|
|
73
|
+
#
|
|
74
|
+
# THE CLAIM IS THE ONE THE EMITTER WRITES. That scope is what makes this check answerable at
|
|
75
|
+
# declaration, and it is why no `if:`/`unless:` is consulted anywhere below: input reflection is
|
|
76
|
+
# static-maximal, so a gated declaration is advertised exactly as an ungated one is and the document
|
|
77
|
+
# carries the collision either way. A member reached through an EXPLICIT intermediate is such a claim
|
|
78
|
+
# like any other: the emitter conjoins it with the node's own property rather than replacing it
|
|
79
|
+
# (PRO-3399), so nothing here has to ask whether a runtime-only claim is enforced on a given call —
|
|
80
|
+
# a question about ActiveModel's gate resolution that would have had to be re-derived here, and the
|
|
81
|
+
# reason the case was once out of scope.
|
|
82
|
+
def check_model_id_object_claim!(tree, field_configs)
|
|
83
|
+
tree.index.each do |config, path|
|
|
84
|
+
next unless config.validations[:model]
|
|
85
|
+
# A model under a `model:` (or otherwise non-nestable) ancestor is never nested by the emitter at
|
|
86
|
+
# all — `apply_nested_subfields!` stops at the blocking node — so neither this model's generated
|
|
87
|
+
# id nor anything beneath it reaches the document, and there is no emitted claim to collide with.
|
|
88
|
+
# Asked through `path_blocked?`, the drop pass's own predicate, so what this skips and what
|
|
89
|
+
# emission omits cannot drift.
|
|
90
|
+
next if Axn::Internal::Reflection::Schema.path_blocked?(path.ancestors)
|
|
91
|
+
|
|
92
|
+
id_key = Internal::FieldConfig.model_id_key(config.field)
|
|
93
|
+
claim = model_id_object_claimant(tree, path, field_configs, id_key)
|
|
94
|
+
raise_model_id_object_claim!(config, *claim, id_key) if claim
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The declaration that claims `id_key` as an object with contents, or nil. Two positions, mirroring the
|
|
99
|
+
# two the emitter reads a sibling from: at depth the id key is a CHILD of the model's own wire parent
|
|
100
|
+
# (`apply_model_id_child!` reads `children[id_field]`), at depth 0 it is another top-level config's own
|
|
101
|
+
# node (`build_input` scans `field_configs` by wire key — not `tree.roots`, which is keyed by reader name
|
|
102
|
+
# and so misses an aliased declaration of the same wire key).
|
|
103
|
+
#
|
|
104
|
+
# A model route at the id key is excluded on both sides: such a config emits its own generated id one
|
|
105
|
+
# level deeper (`<key>_id_id`) and never writes this key at all, the same exclusion `build_input` and
|
|
106
|
+
# `apply_model_id_child!` already apply when they look for an explicit sibling.
|
|
107
|
+
def model_id_object_claimant(tree, path, field_configs, id_key)
|
|
108
|
+
if path.ancestors.empty?
|
|
109
|
+
sibling_nodes = field_configs.filter_map do |c|
|
|
110
|
+
tree.index[c].node if c.field == id_key && !c.validations[:model]
|
|
111
|
+
end
|
|
112
|
+
descendant = sibling_nodes.filter_map { |node| claiming_descendant(node) }.first
|
|
113
|
+
return descendant && [descendant, :nested]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
parent = path.parent_node
|
|
117
|
+
nested = parent.children[id_key]
|
|
118
|
+
descendant = nested && claiming_descendant(nested)
|
|
119
|
+
return [descendant, :nested] if descendant
|
|
120
|
+
|
|
121
|
+
member = claiming_shape_member(Axn::Internal::Reflection::Schema.emitted_shape_sources(path), id_key)
|
|
122
|
+
member && [member, :member]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The declaration whose nesting under `node` makes the key an object. Nil when its own configs forbid
|
|
126
|
+
# nesting (a scalar or `model:` route there emits no object property, so nothing collides with the id).
|
|
127
|
+
#
|
|
128
|
+
# Two sources of contents, because a node has two. Subfield CHILDREN are the tree's own; a node's own
|
|
129
|
+
# `shape:` MEMBERS are not children at all and so were invisible to a `children`-only test — measured,
|
|
130
|
+
# `expects :company_id, on: :payload, type: Hash do … end` beside a `model:` emitted the member's
|
|
131
|
+
# object and dropped the model's id entirely, while the resolver still read that Hash as its token.
|
|
132
|
+
# The block applies to the CHILDREN only. `node_configs_block_nesting?` gates `apply_nested_subfields!`,
|
|
133
|
+
# which is what declines to nest subfield children — but the node's own property was already built by
|
|
134
|
+
# `build_property`, and `apply_structured_schema!` merged the representative's own `shape:` members
|
|
135
|
+
# into it on the way. So a merged node carrying a `model:` route beside a non-model route with its own
|
|
136
|
+
# members still emits that object, and returning early on the block missed it entirely.
|
|
137
|
+
def claiming_descendant(node)
|
|
138
|
+
nested = first_config_below(node) unless Axn::Internal::Reflection::Schema.node_configs_block_nesting?(node.configs)
|
|
139
|
+
|
|
140
|
+
nested || claiming_own_member(node)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A member declared by the node's OWN `shape:` — the representative route's, which is the one
|
|
144
|
+
# `apply_structured_schema!` merges into the emitted property.
|
|
145
|
+
def claiming_own_member(node)
|
|
146
|
+
first_declared_member(Array(Axn::Internal::Reflection::Schema.property_representative(node.configs)))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def first_declared_member(configs)
|
|
150
|
+
configs.each do |config|
|
|
151
|
+
contents = object_contents_of(config)
|
|
152
|
+
return contents if contents
|
|
153
|
+
end
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# What `config` contributes as object CONTENTS at its own node, or nil. Two sources, because a member
|
|
158
|
+
# list is not the whole of what gets emitted:
|
|
159
|
+
#
|
|
160
|
+
# * its DECLARED members, returned as the member config itself so the message can name the
|
|
161
|
+
# declaration the author wrote; and
|
|
162
|
+
# * the properties reflection INFERS, which `shape_property_plan` seeds from a structured `type:`
|
|
163
|
+
# whenever an `of:`/`shape:` key is present at all. A `Data`-typed member with an explicitly
|
|
164
|
+
# EMPTY `shape: { members: [] }` declares nothing and still emits its type's own members — an
|
|
165
|
+
# object property where the lookup token belongs — so reading the raw list alone let it through.
|
|
166
|
+
# There is no config to name for one of these, so the property NAME is returned instead.
|
|
167
|
+
#
|
|
168
|
+
# `in_items` is excluded: there the shape describes the ARRAY'S ELEMENTS rather than the node, so the
|
|
169
|
+
# node is emitted as an array and is no object parent. Declared members are asked first, so the
|
|
170
|
+
# existing answer (and the existing message) is unchanged wherever one exists.
|
|
171
|
+
def object_contents_of(config)
|
|
172
|
+
declared = Axn::Internal::Reflection::Schema.named_members(config.validations.dig(:shape, :members)).first
|
|
173
|
+
return declared.first if declared
|
|
174
|
+
|
|
175
|
+
plan = Axn::Internal::Reflection::Schema.shape_property_plan(config, for_output: false)
|
|
176
|
+
return nil unless plan.emitted && !plan.in_items
|
|
177
|
+
|
|
178
|
+
plan.base_properties.keys.first
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Descends the way the EMITTER does, not the way the tree is shaped. A child the emitter declines to
|
|
182
|
+
# nest contributes no property, so it is no part of the claim: `apply_implicit_node!` drops an
|
|
183
|
+
# implicit child that collides with a non-nestable `shape:` member (a scalar, a mixed union) along
|
|
184
|
+
# with everything beneath it, leaving the parent emitted as a bare object with empty `properties`.
|
|
185
|
+
# A raw recursive walk still found the dropped descendant and reported it as nesting under the key,
|
|
186
|
+
# which was wrong twice over — the declaration was refused, and the message named a path the schema
|
|
187
|
+
# never emits.
|
|
188
|
+
#
|
|
189
|
+
# Asked through `path_blocked?`, the drop pass's own judgment and the same one `PropertyNames`
|
|
190
|
+
# consults per hop, so what this descends into and what emission nests cannot drift. The whole hop
|
|
191
|
+
# chain is passed each time rather than a carried-member accumulator, because that predicate carries
|
|
192
|
+
# internally from the start of the chain — and it is the PUBLIC half of the pair (`blocking_ancestor?`
|
|
193
|
+
# and `merged_shape_members` are private to reflection on purpose), so mirroring it needs no widening
|
|
194
|
+
# of that module's surface.
|
|
195
|
+
#
|
|
196
|
+
# An EXPLICIT child is always emitted as a property — `apply_children!` writes it unconditionally —
|
|
197
|
+
# and `path_blocked?` reports that hop unblocked, so it still counts as contents.
|
|
198
|
+
def first_config_below(node, hops = [])
|
|
199
|
+
node.children.each do |key, child|
|
|
200
|
+
chain = hops + [[node, key]]
|
|
201
|
+
next if Axn::Internal::Reflection::Schema.path_blocked?(chain)
|
|
202
|
+
|
|
203
|
+
return child.config if child.config
|
|
204
|
+
|
|
205
|
+
deeper = first_config_below(child, chain)
|
|
206
|
+
return deeper if deeper
|
|
207
|
+
end
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The `shape:` spelling of the same claim: a member of the model's wire parent named `id_key` that
|
|
212
|
+
# carries members of its own, so `apply_structured_schema!` merges an object property at that key before
|
|
213
|
+
# `apply_model_id_child!` ever runs.
|
|
214
|
+
#
|
|
215
|
+
# No gate is consulted, deliberately: every claim this guard reads is one the emitter WRITES, and
|
|
216
|
+
# input reflection is static-maximal — a gated member is advertised exactly as an ungated one is, so
|
|
217
|
+
# the document carries the collision either way.
|
|
218
|
+
def claiming_shape_member(parent_configs, id_key)
|
|
219
|
+
Axn::Internal::Reflection::Schema.shape_members_at(parent_configs, id_key).find do |member|
|
|
220
|
+
object_contents_of(member)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Names are RENDERED rather than interpolated raw, for the reason `raise_dead_tolerance!` documents: a
|
|
225
|
+
# declared name may hold bytes with no UTF-8 rendering, and joining one into this message would replace
|
|
226
|
+
# the contradiction being reported with an Encoding::CompatibilityError from the reporting itself.
|
|
227
|
+
def raise_model_id_object_claim!(config, claimant, kind, id_key)
|
|
228
|
+
label = ->(name) { Axn::Internal::Reflection::PropertyNames.renderable_label(name) }
|
|
229
|
+
where = config.on ? " (on #{label.call(config.on)})" : ""
|
|
230
|
+
# A claimant is the DECLARATION where there is one, and a bare emitted property name where the
|
|
231
|
+
# contents are inferred from a structured `type:` (see `object_contents_of`).
|
|
232
|
+
named = claimant.respond_to?(:field) ? claimant.field : claimant
|
|
233
|
+
claim =
|
|
234
|
+
if kind == :member
|
|
235
|
+
"a `shape:` member :#{label.call(named)} of the same name declares members of its own"
|
|
236
|
+
else
|
|
237
|
+
via = claimant.respond_to?(:on) && claimant.on ? " (on #{label.call(claimant.on)})" : ""
|
|
238
|
+
":#{label.call(named)}#{via} nests underneath that same key"
|
|
239
|
+
end
|
|
240
|
+
raise ArgumentError,
|
|
241
|
+
"`model:` field :#{label.call(config.field)}#{where} generates the wire key " \
|
|
242
|
+
":#{label.call(id_key)} for its lookup token, but #{claim}. One wire key cannot be both a " \
|
|
243
|
+
"lookup token and a nested object parent — the reflected schema can emit only one of the " \
|
|
244
|
+
"two, and which one survives follows declaration order. Rename the nested key, or drop the " \
|
|
245
|
+
"`model:` on :#{label.call(config.field)}."
|
|
246
|
+
end
|
|
247
|
+
|
|
39
248
|
# The MAP-PARENT check: a subfield read out of a Hash that declares `of:`. `of:` names what every key of
|
|
40
249
|
# that hash maps to, and a subfield names one of those keys — so the two describe the same keys two ways,
|
|
41
250
|
# and no reflected schema can state both. JSON Schema's `additionalProperties` applies only to keys
|