Module: Axn::Internal::Reflection::Schema
- Defined in:
- lib/axn/internal/reflection/schema.rb
Overview
Builds JSON Schema (input/output) from an Axn's declared contract. Read-only, off the execution path — it inspects declared field configs, never runs the action or its validators.
REQUIREDNESS IS DERIVED FROM DECLARED SIGNALS, NOT BY VALIDATING.
A field is omittable (absent from required) when a declared signal says so — a usable default, or a
nil-tolerant validator set (optional:/allow_nil:/allow_blank:). A field that rejects nil by type
alone (allow_empty: true) stays required and non-nullable: emptiness is permitted, absence is not.
We deliberately do NOT run the field's validators against its default to confirm the omitted call
would actually pass; that duplicate-validation pass was expensive and fragile. The tradeoff is a
documented divergence, narrow: a non-blank but otherwise-invalid default (type: String, default: 123; type: :uuid, default: "nope") is reflected as optional though the omitted call
fails at runtime. The safe direction (schema stricter than runtime) never causes failed calls; the
unsafe case above only arises from a self-contradictory contract and surfaces as a normal,
recoverable validation error. A required subfield at ANY depth forces its whole ancestor chain
required and non-nullable (a nil/omitted ancestor yields every descendant absent, PRO-2857).
Defined Under Namespace
Classes: NodeAnnotation, ShapePropertyPlan
Constant Summary collapse
- TYPE_MAP =
{ String => "string", Symbol => "string", Integer => "integer", Float => "number", Numeric => "number", Hash => "object", Array => "array", # NOTE: TrueClass/FalseClass are intentionally absent — TypeValidator accepts only the singleton # value, so single_type_for reflects them as boolean + a single-member enum, not the full domain. Date => "string", DateTime => "string", Time => "string", }.freeze
- FORMAT_MAP =
{ Date => "date", DateTime => "date-time", Time => "date-time", }.freeze
- SIZE_CONSTRAINT_KEYS =
JSON Schema spells the emptiness floor differently per type. A type absent here (integer, boolean, number) has no empty state, so no floor is expressible for it.
{ "array" => :minItems, "object" => :minProperties, "string" => :minLength, }.freeze
- EXCLUDED_FROM_INPUT_SCHEMA =
%i[ambient_context].freeze
- SEGMENT_JUDGED_SCALARS =
The builtin scalars whose reader-method surface we judge as the class's own public methods: an instance answers a segment read iff the declared class publicly defines the method (post-PRO-2886 extraction: a Hash-like source reads any key; everything else is a public_send). Anything outside this list — Data/Struct/custom classes, model records — may answer dynamically, so it is never judged (optimistic: rejection needs proof).
ACCEPTED DIVERGENCE from the strict no-false-rejection doctrine. TypeValidator is
is_a?, so atype: Stringvalue can be a String SUBCLASS that adds methods, or a plain String carrying a singleton method — either is contract-valid yet answers a segment this judgment refutes. We judge anyway, deliberately: the approved design takes the DECLARED class's method surface as the contract (type: Stringpromises the String surface, not whatever an exotic subclass bolts on), so a subclass adding readers doesn't hold the declaration hostage. The conventional instance of each listed class IS exactly that class, so the judgment matches real inputs; the subclass/ singleton case is the narrow, documented exception. The membership test below isk <= s, so a declared class equal to (or a subclass of) a judged entry is judged on that entry's surface.NumericandDateare excluded — the boundary is drawn narrower there for a different reason: every contract-validtype: Numericvalue is a STRICT subclass (Integer/Float/Rational/ BigDecimal/…) whose surface is wider thanNumericitself (Integer#bit_lengthexists butNumeric.public_method_defined?(:bit_length)is false), andtype: DateadmitsDateTime(addinghour/minute/…). There the subclass IS the conventional instance, so judging on the abstract class would refute a segment ordinary valid input answers — a real false positive — so both stay optimistic, same as Data/Struct/unknown classes. [String, Symbol, Integer, Float, Array, DateTime, Time, TrueClass, FalseClass].freeze
- FRAMEWORK_SERIALIZATION_OWNERS =
active_support reopens Data/Struct/Hash (and Object) with member-keyed
as_json/to_h; those owners are safe. Any other owner means the value class (or an included module) overrides the method, which serialize_value would follow — so the serialized shape is no longer provably an object keyed by the declared members. [Data, Struct, Hash, Object].freeze
- FLIPPABLE_JSON_TYPES =
Whether inbound coercion could flip the Ruby truthiness of the referenced field between its wire value and its settled value — the ONLY way coercion changes a truthiness judgment, and the reason an unless: gate can't be emitted declaratively for such a field. Coerce-or-leave (Coercion.coerce_value) transforms String wire values through the parse-based COERCERS, and — for a
:booleantarget specifically — a non-String value too (Coercion#coerce_boolean also accepts an Integer, per its acceptance table: idempotent true/false, integer 0/1, and FALSY_STRINGS/TRUTHY_STRINGS). Among the coercible targets (Coercion::SUPPORTED) only:booleanmaps a truthy wire value to a falsey Ruby value — Date/Time/Integer/Float/Symbol all yield a truthy value from a truthy String, and a schema-valid boolean is already true/false (idempotent, no flip). A flip is therefore possible only when the ref's declared type BOTH (a) admits the:booleancoercion branch AND (b) admits some OTHER branch whose schema-valid wire values include one coerce_boolean maps to false — i.e. a branch admitting a FALSY_STRINGS member (a JSONstringbranch) or admitting integer0(a JSONinteger/numberbranch, since coerce_boolean checksvalue.zero?before any type-specific parse). Astring+format branch (Date/Time) still counts: JSON Schema treatsformatas annotation-only by default, so the schema still admits an arbitrary String wire value the coercer can reach. A plain:boolean-only property emits no other branch, so no schema-valid input can reach the falsey path — no flip. AND (c) coercion isn't explicitly disabled: explicitcoerce: falsecan't flip; an explicitcoerce: truecan; an ABSENT flag with a coercible branch is treated as flippable (the class-levelcoerce_input_typesoverride may enable coercion, and reflection must not resolve per-class config — conservative toward the safe fallback). Declared-config inspection only, side-effect-free (single_type_for is pure). %w[string integer number].freeze
- PARAMS_CLASS_NAME =
Parameters is identified by rendered class NAME rather than by the constant: this file is one an adapter gem loads directly, and naming a Rails constant here would put an unresolvable reference in its load graph for every consumer running without Rails. It is the same identify-by-name form TypeValidator already uses to recognize a test double, and the rendering is read natively (
Internal::ClassName.of_module) so a class cannot answer this question for itself. "ActionController::Parameters"- EMPTY_CONTAINER_CLASSES =
The container classes whose
empty?is RUBY'S OWN — the ones the emptiness axis is declared on.Setsits behinddefined?becausesetis not always loaded. [::Hash, ::Array, ::String].freeze
Class Method Summary collapse
-
.annotate_node!(node, ann, satisfiability: false) ⇒ Object
Post-order: a node's annotation only depends on its (already-annotated) children.
-
.apply_children!(prop, children, parent_configs, ann) ⇒ Object
Emits one level of children into
prop(which must already have :properties/:required arrays), recursing into each child's own subtree. -
.apply_implicit_node!(prop, key, node, parent_configs, ann) ⇒ Object
An implicit node (a dotted-path intermediate with no declaration of its own) emits a bare object property whose only content is its children.
-
.apply_member_size_constraints(members, minimum) ⇒ Object
A union emits one branch per member type instead of a single
type:, and the validators reject empty whichever branch the value takes — so the floor belongs on every branch that could hold an empty value. -
.apply_model_id_requiredness!(config, children, field_configs, properties, required, ann) ⇒ Object
A model lookup needs a non-nil token.
-
.apply_nested_subfields!(prop, node, ann) ⇒ Object
Mutates
propto nest the node's children asprop[:properties]/prop[:required], recursing through the whole subtree. -
.apply_size_constraints!(prop, config) ⇒ Object
The emptiness axis, as JSON Schema sees it:
minItems/minProperties/minLengthkeyed off the emitted type. -
.apply_structured_schema!(prop, config, for_output:) ⇒ Object
Combine of: (bare element baseline) and shape: (typed member contracts) into items:/properties:.
-
.apply_type_info!(prop, type_info, config, nullable:) ⇒ Object
Writes the resolved JSON type (and nullability/format/singleton-enum) from json_type_for into prop.
-
.blank_default_rejected?(config) ⇒ Boolean
Whether this field's own checks would reject the blank/empty literal value its default supplies — THE single definition of "can this default relax the field", read both when judging the default's usability and when deciding requiredness (an omitted call resolves the default, so a rejected one cannot be omitted).
- .boolean_coercion_can_flip_truthiness?(ref) ⇒ Boolean
-
.branch_answers_segment?(branch, segment) ⇒ Boolean
Whether ONE admissible declared branch can answer reading
segmentoff its value. - .build_input(field_configs, subfield_configs = [], resolved: nil, klass: nil) ⇒ Object
-
.build_input_for(klass) ⇒ Object
Subfields nest recursively: a dotted
on:path, a subfield of a subfield, and a dotted field name all become nested object properties keyed by wire key (SubfieldTree resolves reader aliases and dotted segments once, up front). -
.build_output(field_configs) ⇒ Object
Every exposed field is always present in the serialized output: Values.serialize_exposed iterates every outbound config and emits its key (value nil when unset).
- .build_property(config, for_output: false, subfield: false) ⇒ Object
-
.children_require_presence?(children, ann) ⇒ Boolean
Whether any direct child node may NOT be omitted from the parent object — a read of each child's own precomputed annotation, never a fresh descent into its subtree.
-
.condition_reference(rule, field_configs) ⇒ Object
The declared top-level inbound field a Symbol condition reads: an exact reader-name match, or — for a
?-suffixed Symbol — the boolean field whose generated predicate alias it names. -
.conditional_requiredness_clause(config, field_configs, node, klass) ⇒ Object
An exact JSON Schema conditional for a gated-but-otherwise-required top-level field whose single Symbol condition references a declared sibling field.
-
.conditionally_gated?(config) ⇒ Boolean
Whether the config's declaration carries a declaration-level if:/unless: gate — the signal that its enforcement (NOT its shape) is conditional at runtime.
-
.config_answers_segment?(config, segment) ⇒ Boolean
Whether a config's declared type admits SOME branch that can answer
segment. -
.credit_sibling_id_defaults!(node, ann) ⇒ Object
Satisfiability-only post-adjustment (runs before this node's own requiredness is computed, so the credit propagates up every ancestor): a model-routed child that a sibling
<key>_idsubfield can rescue is re-annotated non-required. - .custom_serialization?(klass, method) ⇒ Boolean
-
.declared_attribute(config, name) ⇒ Object
An attribute a config may or may not carry, read tolerantly:
#descriptionand#default, enumerated at each call site. -
.declared_size_minimum(config) ⇒ Object
The smallest size this field's validators admit, or nil when they admit an empty value.
-
.derive_annotations(roots, satisfiability: false) ⇒ Object
One bottom-up pass over the whole subfield tree, computed once from build_input and threaded through every emission site below (apply_nested_subfields!/apply_children!/apply_implicit_node!/ apply_model_id_requiredness!) instead of each of them independently re-walking the subtree via subtree_requires_presence?/required_child? — the repeated-recomputation pattern behind PR #149's rounds-5/8/9 findings (a dropped/blocked deep shape agreeing at some sites but not others).
-
.drop_uuid_format(members) ⇒ Object
Strip
format: "uuid"from anyOf members: a blank-tolerant uuid accepts "" at runtime, which a strictformat: uuidvalidator would reject (mirrors the scalar-type relaxation above). -
.dropped_deep_subfields(field_configs, subfield_configs, resolved: nil) ⇒ Object
The subfield configs build_input omits from the input schema: deep configs (a dotted
on:path, a subfield of a subfield, or a dotted field name) whose chain passes through amodel:or non-object parent, so they have no JSON-object representation. -
.dropped_from_deep_paths(deep_paths) ⇒ Object
The judgment over a tree's deep candidates: which of the
[config, hops]pairs SubfieldTree.build collected (a config reached through more than one hop) have no JSON-object representation. -
.effective_entry_options(entry, declaration_options) ⇒ Object
An entry's options as
validateswill hand them over — the declaration-wide shared options with the entry's own merged on top, so a shared tolerance or context is judged here exactly as at runtime. -
.effective_validations(validations, for_output:) ⇒ Object
THE ONE derivation of the validations a projection is BUILT from, and the reason it is a function rather than a step inside
build_property: a per-validator (nested) gate can skip an INDIVIDUAL check on a given call (type: { klass: Integer, if: :flag }withflagfalsey lets a nonblank wrong-typed value through), so its constraint can't be promised outbound — and every rule DERIVED from what the projection emits has to start from the same reduced view, or it describes a schema the emitter never emits. -
.empty_container?(value) ⇒ Boolean
Whether a default is an EMPTY container, decided by WHOSE
empty?would answer it. -
.empty_default?(value) ⇒ Boolean
Whether a default is EMPTY — the question
allow_empty: false's own check asks of a value, which is not blankness: a whitespace-only String is blank but not empty, andfalsehas no empty state at all. -
.empty_value_rejected?(validations) ⇒ Boolean
Whether an empty value is rejected by something OTHER than the author's own
length:— eitherallow_empty: false's own check or a live presence check (every empty value is blank, so a presence check that rejects blank rejects every empty value). - .entry_context_scoped?(opt) ⇒ Boolean
-
.entry_effective_gate_keys(entry_opts, decl_gates) ⇒ Object
Which gate keys EFFECTIVELY gate a single validator entry, given the declaration-level gates (
decl_gates= the sliced :if/:unless off the whole declaration, already blank-canonicalized). -
.entry_mentions_gate_key?(opt) ⇒ Boolean
Whether a single validator ENTRY's options MENTION a per-validator gate key at all — blank or not (contrast entry_self_gated?, which requires a NON-blank value).
-
.entry_self_gated?(opt) ⇒ Boolean
Whether a single validator ENTRY carries a real per-validator (nested) if:/unless: gate — one that can skip that entry alone (e.g.
presence: { if: -> { ... } },type: { klass: Integer, if: :flag }). -
.enum_for_inclusion(enum_values, nullable:) ⇒ Object
The
enum:member list for an inclusion set. - .enum_scalar_type(value) ⇒ Object
-
.field_optional?(config, children, ann, satisfiability: false) ⇒ Boolean
A field is absent from
requiredwhen a declared signal makes it omittable. -
.framework_generated_reader?(klass, rule_name) ⇒ Boolean
Whether the method a Symbol condition names still resolves to the reader Axn generated (not a user method that would evaluate against the settled value instead of the wire value).
- .gated_validations?(validations) ⇒ Boolean
-
.inclusion_enum_values(inclusion) ⇒ Object
The literal membership set of an
inclusion:validator, whether declared as the hash long form ({ in: [...] } / { within: [...] }) or the equivalent bare-Array shorthand (inclusion: %w[a b c]). - .items_schema_for(of_validations, for_output: false) ⇒ Object
- .json_type_for(validations, for_output: false) ⇒ Object
- .member_keyed_object_type?(klass) ⇒ Boolean
-
.member_name(member) ⇒ Object
A member's NAME, or nil when it has none.
-
.member_properties(members, for_output:) ⇒ Object
A DECLARED member's
fieldis already the Symbol the declaration walk judged it under (ShapeConfignormalizes, and the walk canonicalizes a duck-typed member's name once, beside the duplicate check). -
.model_id_property(config) ⇒ Object
Returns [id_field_symbol, prop_hash] for a model: config.
-
.named_members(members) ⇒ Object
The members of a shape that actually name a property, paired with that name.
- .native_empty_owner?(owner) ⇒ Boolean
-
.nestable_as_object?(config) ⇒ Boolean
ALL admissible branches are object-shaped — so the subfields may nest as
propertieswithout rejecting a valid non-object branch. -
.nil_accepted?(config) ⇒ Boolean
Whether the field's validators, taken together, permit a nil/omitted value — the one question requiredness and nullability turn on, owned by Validation::Base so a field config's own
optional?answers it identically. - .nil_allowed?(config) ⇒ Boolean
-
.nil_tolerance_rescues_absence?(config, satisfiability: false) ⇒ Boolean
Whether this config's nil-tolerance actually rescues an ABSENT value.
- .nil_tolerant_validation?(key, opt, declaration_options) ⇒ Boolean
-
.node_configs_block_nesting?(configs) ⇒ Boolean
Whether the configs declared at a subfield node forbid nesting its children as object properties: a
model:route (the client sends<field>_id, not the object) or a non-nestable type (a non-object type or a mixed union) on ANY config. -
.node_optional?(node, ann, configs = node.configs, satisfiability: false) ⇒ Boolean
Whether a node may be absent from its parent object.
-
.normalize_scalar_literal(value) ⇒ Object
A literal the serializer refuses outright — a non-finite
default: Float::INFINITY, which no JSONdefaultcould carry — is reported exactly as declared. -
.normalize_schema_literal(value) ⇒ Object
Deep-copy a reflected literal (a
default:value or an inclusion enum member) and normalize any leaf whose JSON wire form differs from its Ruby form — Time/DateTime/Date → iso8601 String, Symbol → String, non-Integer/Float Numeric (BigDecimal/Rational) → Float — so the emitteddefault/enummatches the property's advertised type. -
.object_shaped?(config) ⇒ Boolean
Whether a field's declared type can be represented as a JSON object (so its subfields can nest as object properties): Hash,
:params, or untyped. - .object_type_branches(config) ⇒ Object
-
.object_typed_element?(klass) ⇒ Boolean
Whether an element type is an OBJECT on the wire a client sends (input): Hash/
:params/Data/Struct. -
.optional_for_schema?(config, subfield: false, satisfiability: false) ⇒ Boolean
Optional (client may omit) iff a usable default exists, or — with no usable default — the validators tolerate a nil/omitted value.
-
.path_blocked?(hops) ⇒ Boolean
Walk a deep config's ancestor chain hop by hop, carrying the shape members an implicit hop merged into so a deeper implicit hop can test their OWN nested shape members (a member-of-a-member).
-
.presence_blank?(value) ⇒ Boolean
A default value ActiveModel's presence validator treats as blank (and so rejects):
false, a whitespace-only String, or an empty container. -
.presence_rejects_blank?(validations) ⇒ Boolean
Whether an active
presence:check here rejects every blank value: one is declared, it is not blank-tolerant, and it is not context-scoped (an entry that runs on no call rejects nothing). -
.property_representative(configs) ⇒ Object
The config a subfield node's own object property is BUILT from: the first route that is not a
model:one (a model route emits<leaf>_idin place of the object, so it shapes no object property). -
.reject_null!(prop) ⇒ Object
Forbid
nullon a property (a required model-id token can't be null). -
.required_child?(config, children, ann) ⇒ Boolean
Whether a nil/absent parent leaves a required nested obligation unmet — so it can't validate and the parent is neither omittable nor nullable.
-
.required_key(name) ⇒ Object
The
requiredentry for a property keyed byname: a String holding the bytes that name is KEYED by. -
.required_shape_member?(config) ⇒ Boolean
Whether the parent's shape (
do…end) block declares a member that isn't schema-optional. -
.requiredness_conditionally_relaxable?(config) ⇒ Boolean
Whether a config's requiredness can be RELAXED at runtime by a conditional GATE — the signal that a required-looking route can't oblige an omitted/nil ancestor to be present, because a closed gate skips the check that would otherwise reject the nil ancestor.
- .set_includes_nil?(opt) ⇒ Boolean
-
.shape_members_at(parent_configs, key) ⇒ Object
Every
shape:member declared atkeyacrossparent_configs(the implicit node collides with them). -
.shape_overlay_applies?(of_validations, for_output:) ⇒ Boolean
Whether a shape block should overlay object properties onto an array's items.
- .shape_property_plan(config, for_output:) ⇒ Object
-
.shape_serializes_to_object?(validations) ⇒ Boolean
Whether a shaped field's value serializes to a member-keyed JSON object (so advertising
object+ the shape's properties on OUTPUT matches serialize_exposed). -
.shaped_items_serialize_to_object?(of_validations) ⇒ Boolean
Whether an
of:element type provably serializes to a member-keyed object (output items). -
.shared_validation_options(config) ⇒ Object
The declaration-wide options every entry of a config rides alongside — the tier the per-entry judgments resolve against.
-
.sibling_id_rescued?(parent, key, node) ⇒ Boolean
Whether a node's model route is rescued by a sibling
<key>_iddefault — the SINGLE source of truth for both the satisfiability annotation credit (credit_sibling_id_defaults!) and SubfieldContradictions' per-config tolerance loop, so the two can't drift on which nodes the id rescues. - .single_items_schema(klass, for_output: false) ⇒ Object
- .single_type_for(klass, for_output:) ⇒ Object
-
.size_constraint_key_for(type) ⇒ Object
The JSON Schema floor key for an emitted type, or nil for a type with no empty state.
-
.subtree_requires_presence?(node, ann) ⇒ Boolean
Whether omitting/nil-ing this node's value strands a required descendant — the transitive extension of the one-level required-child test.
-
.synthesizable?(config) ⇒ Boolean
Whether an object (
{}) could stand in for this config's value: its declared type must admit an object AND it must not be amodel:route (a{}there is rejected by ModelValidator and would be preferred by the model resolver over a caller-supplied<field>_id). -
.type_allows_blank?(config) ⇒ Boolean
Whether the TYPE validator itself tolerates a blank value (
type: :uuid, allow_blank: truefoldsallow_blankinto the type validator's options). -
.usable_default?(config, subfield:, satisfiability: false) ⇒ Boolean
A default lets the client omit the field (Axn applies it before validation).
-
.usable_id_token_default?(config) ⇒ Boolean
Whether an
<field>_iddefault can actually serve as a model LOOKUP token — the shared test for every id-rescue site (sibling_id_rescued?, which serves both the annotation credit and the contradictions loop, and SubfieldContradictions' model_omittable?). - .validator_entry_options(entry) ⇒ Object
Class Method Details
.annotate_node!(node, ann, satisfiability: false) ⇒ Object
Post-order: a node's annotation only depends on its (already-annotated) children.
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 |
# File 'lib/axn/internal/reflection/schema.rb', line 433 def annotate_node!(node, ann, satisfiability: false) node.children.each_value { |child| annotate_node!(child, ann, satisfiability:) } credit_sibling_id_defaults!(node, ann) if satisfiability # ANCESTOR-FORCING is derived from the RELAXABLE-filtered subset of the node's configs: a route # whose requiredness a conditional gate can relax at runtime can't oblige an omitted/nil # ancestor to be present — only a route with an UNGATED nil-rejecting check can. That covers # both a declaration-level gate (`if:`/`unless:` on the whole declaration) AND a per-validator # nested gate on every check that could reject nil (e.g. `presence: { if: -> { data.present? } }` # — the presence is gated off when the ancestor is absent, so the omitted ancestor validates). # Passing the filtered subset to node_optional? (rather than the full set, then subtracting a # fully-gated node afterward) is what makes a MIXED node correct: a node merged from an # ungated-but-omittable route (e.g. `optional: true`) and a gated-required route forces nothing, # because its only ancestor-relevant obligation — the ungated route — is itself omittable. The # prior two-step form (full-set node_optional? then relax only when EVERY config is gated) # over-forced exactly that shape, wrongly rejecting a runtime-valid contract in satisfiability mode. # # This is ONLY the ancestor-propagation signal. Own-level emission stays static-maximal: the # emission sites (apply_children!/field_optional?) call node_optional? with the full or # per-route config set directly, so a gated route's own nested `required` obligation is # unchanged. Edge cases preserved: an implicit node ignores the `configs` param inside # node_optional? (a pure subtree test), so its ancestor-forcing is untouched; a fully-relaxable # node yields an empty subset, and `[].all?` is vacuously true → node_optional? true → not # required; an all-ungated node passes its full set (unchanged). # The satisfiability short-circuit inside node_optional? (the usable_default? line) still reads # the FULL node.configs regardless of the param, so a node-level default keeps rescuing every # route. Mode-independent: satisfiability mode needs it so a declared tolerance above a gated # child is exercisable (not dead), and strict mode honors the ancestor's own declared optionality # instead of inventing strictness the declaration disavowed (the design doc's "one deliberate # exception"). required = !node_optional?(node, ann, node.configs.reject { |c| requiredness_conditionally_relaxable?(c) }, satisfiability:) if node.implicit? # An implicit node's nullability has no config of its own to consult (required IS the transitive # presence test here), so it's simply the inverse. nullable = !required else # required_child? (and apply_nested_subfields!'s nullability line it feeds) always reasons about # the node's non-model representative config — the same one apply_children! emits the property from, # read through the one owner of that rule (property_representative). A node with no non-model # config (a pure model: route) never nests, so its nullable is unused; false is an inert default. representative = property_representative(node.configs) nullable = representative ? nil_allowed?(representative) && !required_child?(representative, node.children, ann) : false end ann[node] = NodeAnnotation.new(required:, nullable:) end |
.apply_children!(prop, children, parent_configs, ann) ⇒ Object
Emits one level of children into prop (which must already have :properties/:required arrays),
recursing into each child's own subtree. parent_configs are the configs whose subfields these
children are — used to decide, by the same predicate as the drop pass, whether an implicit child
may merge into a colliding shape member. They are the top-level/subfield configs at an explicit
parent (ALL of them at a merged node, mirroring SubfieldTree), or the shape members an implicit
intermediate merged into (so nested members block at depth), or empty for a fresh implicit
intermediate that claimed no shape member.
A single wire path can be declared via two routes (Node#configs size > 1), and the routes can
disagree on kind: a model: route emits the generated <leaf>_id while a plain route emits the
object property. Both are enforced at runtime, so both are emitted, each required per its OWN
route's configs — not the node as a whole.
ACCEPTED DIVERGENCE (looser-than-runtime, the only such case here): at a merged model+non-model
node the non-model route's raw-key object property admits an object value that runtime ALWAYS
rejects — the model resolver reads the raw key as the record, and a JSON object is never a model
instance, so only absent/null are JSON-satisfiable. Left as-is: sending the object yields a normal,
recoverable validation error, and the generated <leaf>_id already advertises the working path.
990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 |
# File 'lib/axn/internal/reflection/schema.rb', line 990 def apply_children!(prop, children, parent_configs, ann) required_model_ids = [] children.each do |key, node| if node.implicit? apply_implicit_node!(prop, key, node, parent_configs, ann) next end model_configs = node.configs.select { |c| c.validations[:model] } non_model_configs = node.configs.reject { |c| c.validations[:model] } # The object property is built from ONE of them; see property_representative, which every layer that # has to name that config reads (requiredness annotation, and the size cap's shape charge). unless model_configs.empty? # The id key derives from the LEAF wire segment (a dotted model name digs `<leaf>_id` off # the same nested parent at runtime). A user may declare an explicit nested `<field>_id` # subfield; don't clobber it with the generic model-generated one. id_field = Internal::FieldConfig.model_id_key(key) _, subprop = model_id_property(model_configs.first) prop[:properties][id_field] ||= subprop unless node_optional?(node, ann, model_configs) prop[:required] << id_field.to_s required_model_ids << id_field end end representative = property_representative(node.configs) next unless representative child_prop = build_property(representative, subfield: true) apply_nested_subfields!(child_prop, node, ann) # `null` survives only when every non-model route tolerates nil (runtime enforces all of them; # the property itself is built from the first non-model config) AND no required descendant is # stranded — a nil node yields every descendant absent (PRO-2857), so a required one below it # forbids nil even for a non-object node whose subfield shape isn't nested here. null_ok = non_model_configs.all? { |c| nil_allowed?(c) } && !subtree_requires_presence?(node, ann) reject_null!(child_prop) unless null_ok prop[:properties][key] = child_prop.compact prop[:required] << required_key(key) unless node_optional?(node, ann, non_model_configs) end # A required nested model id can't be null (a null token resolves the model to nil at runtime). # Done after the loop so it survives an explicit id subfield declared after the model: subfield. required_model_ids.each { |id_field| reject_null!(prop[:properties][id_field]) if prop[:properties][id_field] } end |
.apply_implicit_node!(prop, key, node, parent_configs, ann) ⇒ Object
An implicit node (a dotted-path intermediate with no declaration of its own) emits a bare object
property whose only content is its children. When a shape: member of any parent_configs
claims the key, merge into it only if EVERY colliding member is nestable_as_object? — the SAME
predicate on the SAME member configs that blocking_ancestor? uses (it scans ALL of
the node's configs), so emission and the drop pass agree: a non-nestable member (a scalar, or a
mixed union like type: [Hash, Array]) on ANY route blocks and its deep configs stay in
dropped_deep_subfields rather than forcing a self-contradictory property. The block is judged from
the member configs directly, NOT from a pre-seeded property: at a merged node the object property
is built from the first non-model config, so a scalar member declared on a LATER config seeds
nothing to collide with, yet must still block (matching SubfieldTree, which scans every config).
A blocked merge omits the deep SHAPE but not the deep OBLIGATION: runtime validates the dropped
subfields regardless of representability, so when the dropped subtree requires presence
(subtree_requires_presence? — the same predicate used everywhere) the colliding member's own
property still inherits that obligation. The member is forced required and its null admission
stripped (reject_null! handles both type: arrays and anyOf unions) — because a nil/absent
member strands the required descendant (PRO-2857). Nothing else about the member is touched (no
forced object type, no properties — its shape stays dropped). An all-optional dropped subtree
strands nothing, so the member keeps its declared flags (runtime accepts omission/nil there).
1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 |
# File 'lib/axn/internal/reflection/schema.rb', line 1054 def apply_implicit_node!(prop, key, node, parent_configs, ann) members = shape_members_at(parent_configs, key) if members.any? { |member| !nestable_as_object?(member) } if subtree_requires_presence?(node, ann) prop[:required] << required_key(key) reject_null!(prop[:properties][key]) if prop[:properties][key] end return end # Carry the (all-nestable) colliding members as the parent configs for this node's own children, # so a deeper implicit hop tests their NESTED shape members (a member-of-a-member). Same members # the drop pass carries, so the two agree at depth. existing = prop[:properties][key] target = existing || {} target.delete(:format) target[:properties] ||= {} target[:required] ||= [] apply_children!(target, node.children, members, ann) target[:required] = target[:required].uniq # A fresh implicit intermediate is nullable exactly when nothing beneath requires presence (a nil # parent digs every descendant to nil, PRO-2857) — the precomputed annotation's bare nullable (an # implicit node has no config of its own to collide against). A shape-member collision additionally # caps it by the members' OWN nil-tolerance — nullable only when EVERY colliding member tolerates # nil (runtime enforces all routes), read from each config via nil_allowed? (the same predicate the # parent nesting uses) never sniffed off the emitted property: an untyped nil-tolerant member emits # no `type`, so a null branch is invisible there and property-sniffing would force it non-nullable # though runtime accepts a nil member. With no colliding member, an existing merge target (e.g. a # Data placeholder property with no shape member) falls back to non-nullable (stricter than # runtime), while a genuinely fresh node (no property, no member) follows its subtree. nullable = ann[node].nullable && (members.any? ? members.all? { |m| nil_allowed?(m) } : existing.nil?) target[:type] = nullable ? %w[object null] : "object" target[:required] = nil if target[:required].empty? prop[:properties][key] = target.compact prop[:required] << required_key(key) if ann[node].required end |
.apply_member_size_constraints(members, minimum) ⇒ Object
A union emits one branch per member type instead of a single type:, and the validators reject empty
whichever branch the value takes — so the floor belongs on every branch that could hold an empty
value. A branch with no empty state (an integer member) and the nullability branch carry none,
decided by the same size-bearing test the single-type path uses, applied per branch.
1272 1273 1274 1275 1276 1277 |
# File 'lib/axn/internal/reflection/schema.rb', line 1272 def apply_member_size_constraints(members, minimum) members.map do |member| key = size_constraint_key_for(member[:type]) key ? member.merge(key => minimum) : member end end |
.apply_model_id_requiredness!(config, children, field_configs, properties, required, ann) ⇒ Object
A model lookup needs a non-nil token. Single source of truth for the generated <field>_id's
requiredness AND nullability, considering the model field plus any explicit <field>_id sibling
(order-independent — runs after all properties are built).
The id is OMITTABLE only when the model field itself is omittable (a nil-tolerant model, or one
with its own usable default) AND no descendant requires presence per its own annotation (a
defaulted descendant is self-rescuing at read time). A subfield default now applies at read time
at any depth under a model — value-level defaults, PRO-2889, no synthesis involved — so a
defaulted descendant resolves to its own value and never forces the id; only a descendant with no
rescuing signal (no usable default, not nil-tolerant) strands an omitted record and keeps the id
required. OR an explicit <field>_id sibling carries a usable DEFAULT (inbound defaults supply
the token before the lookup). A merely nullable/optional explicit id with no default doesn't help.
When the id IS required it also can't be null, so any null branch is stripped.
KNOWN LIMITATION (accepted divergence): this covers a shallow model field and its explicit shallow
id sibling. Self-referential id/model contracts nested under a parent (a model: subfield with a
sibling defaulted <field>_id subfield) are not reconciled here — the parent may reflect as
required though runtime synthesizes it. That is the safe direction (stricter than runtime).
1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 |
# File 'lib/axn/internal/reflection/schema.rb', line 1572 def apply_model_id_requiredness!(config, children, field_configs, properties, required, ann) id_field, = model_id_property(config) explicit_id = field_configs.find { |c| c.field == id_field } # A default at ANY depth under the model applies at read time (value-level defaults, # PRO-2889) — no synthesis is involved — so descendant omittability is the ordinary # annotation-derived rule, same as every other parent. model_omittable = optional_for_schema?(config) && !children_require_presence?(children, ann) return if model_omittable || (explicit_id && usable_default?(explicit_id, subfield: false)) key = id_field.to_s required << key unless required.include?(key) reject_null!(properties[id_field]) if properties[id_field] end |
.apply_nested_subfields!(prop, node, ann) ⇒ Object
Mutates prop to nest the node's children as prop[:properties]/prop[:required], recursing
through the whole subtree. Forces the parent to type: object (it now has structure). The parent
is nullable only when it tolerates nil AND strands no required descendant: runtime treats a nil
parent as "subfields absent" (PRO-2857), so a nil-accepting parent with an all-optional subtree
accepts null, while a required descendant (which a nil parent can't yield) keeps it object-only.
Only applies when EVERY admissible parent type is object-shaped (Hash/:params/untyped) — a
non-object parent (type: Array) or a mixed union (type: [Hash, Array]) keeps its declared
type(s) and its subfields' shape is omitted, since object properties can't represent a non-object
branch (deep descendants there are in dropped_deep_subfields; its children still shape
requiredness via required_child?, matching runtime).
node's own representative config (the FIRST non-model config at a merged node) shapes the
property itself (type, nullability) — see NodeAnnotation. node.configs is EVERY config at the
node: it decides both whether to nest at all (node_configs_block_nesting?, the same predicate the
drop pass uses, so a route the tree drops from is never re-nested) and, threaded on as parent
configs, which shape: members might collide with an implicit child.
937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 |
# File 'lib/axn/internal/reflection/schema.rb', line 937 def apply_nested_subfields!(prop, node, ann) children = node.children return if children.empty? node_configs = node.configs if node_configs_block_nesting?(node_configs) # A non-nestable parent (non-object type, mixed union, or model route) omits its children's # SHAPE but NOT their OBLIGATION: field_optional? still forces the parent required when a child # requires presence, so its nullability must agree. A nil parent yields every descendant absent # (PRO-2857), stranding the required descendant, so strip the parent's `null` admission # (reject_null! handles both a type array and an anyOf union) — mirroring the nested-child guard # in apply_children!. Predicate: children_require_presence?(children), the same transitive # presence test as the nested analog's subtree_requires_presence?(node); required_child?'s # shape-synthesis clause is inert for a non-object parent, so the plain presence test is exact # and keeps the two sites' reasoning identical. reject_null!(prop) if children_require_presence?(children, ann) return end prop.delete(:format) prop[:properties] ||= {} prop[:required] ||= [] apply_children!(prop, children, node_configs, ann) prop[:required] = prop[:required].uniq # A nil parent yields its subfields as absent, so `null` is admissible exactly when the parent # accepts nil and no required nested obligation is stranded (required_child? — which counts a # required shape member only when the parent's OWN default materializes it). Read from the # precomputed annotation (derive_annotations already applied this same rule to `node`), NOT # `prop[:required]`, which also carries shape members that a bare nil parent never triggers. prop[:type] = ann[node].nullable ? %w[object null] : "object" prop[:required] = nil if prop[:required].empty? end |
.apply_size_constraints!(prop, config) ⇒ Object
The emptiness axis, as JSON Schema sees it: minItems/minProperties/minLength keyed off the
emitted type. A field rejects empty when it carries an explicit length minimum, or when the default
presence check applies without blank-tolerance — presence is !blank?, so it forbids the empty value
too. Only allow_blank is consulted, never allow_nil: nil-tolerance is the other axis and says
nothing about whether an empty value is admissible. Emitting this is what keeps a required
collection's schema from advertising [] as acceptable when the runtime rejects it — which is why it
follows the emitted type into a union's anyOf branches as well as a single type:.
For a String under presence: the runtime also rejects whitespace-only values, which
minLength cannot express, so the emitted constraint stays a floor rather than an exact mirror.
1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 |
# File 'lib/axn/internal/reflection/schema.rb', line 1257 def apply_size_constraints!(prop, config) minimum = declared_size_minimum(config) return unless minimum if prop[:anyOf] prop[:anyOf] = apply_member_size_constraints(prop[:anyOf], minimum) elsif (key = size_constraint_key_for(prop[:type])) prop[key] = minimum end end |
.apply_structured_schema!(prop, config, for_output:) ⇒ Object
Combine of: (bare element baseline) and shape: (typed member contracts) into items:/properties:. Precedence: shape: enriches/overrides of: baseline.
1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 |
# File 'lib/axn/internal/reflection/schema.rb', line 1326 def apply_structured_schema!(prop, config, for_output:) return unless config.validations[:of] || config.validations[:shape] plan = shape_property_plan(config, for_output:) # The shape the PLAN carries, never a second read of the config: one answer to which members are # emitted here, so a rule charged against the plan and this emission cannot walk different lists. shape = plan.shape if plan.in_items? # The plan's own type schema, not a second `items_schema_for` call: one build, and the plan is then # literally what gets emitted rather than a parallel derivation of it. items = plan.type_schema if shape && plan.emitted member_props, required = member_properties(shape[:members], for_output:) items = items.merge(type: "object", properties: plan.base_properties.merge(member_props)) items[:required] = required unless required.empty? end prop[:items] = items unless items.empty? elsif shape return unless plan.emitted prop[:type] = nil_allowed?(config) ? %w[object null] : "object" prop.delete(:format) member_props, required = member_properties(shape[:members], for_output:) prop[:properties] = plan.base_properties.merge(member_props) prop[:required] = required unless required.empty? end end |
.apply_type_info!(prop, type_info, config, nullable:) ⇒ Object
Writes the resolved JSON type (and nullability/format/singleton-enum) from json_type_for into prop.
1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 |
# File 'lib/axn/internal/reflection/schema.rb', line 1232 def apply_type_info!(prop, type_info, config, nullable:) if type_info[:anyOf] members = type_info[:anyOf] members = drop_uuid_format(members) if type_allows_blank?(config) prop[:anyOf] = nullable ? members + [{ type: "null" }] : members elsif type_info[:type] prop[:type] = nullable ? [type_info[:type], "null"] : type_info[:type] # A `type: :uuid, allow_blank: true` field accepts "" at runtime (TypeValidator treats a blank # uuid as valid under allow_blank), but a strict `format: "uuid"` validator would reject "". # Drop the uuid format there so the schema doesn't reject a value the contract accepts. prop[:format] = type_info[:format] if type_info[:format] && !(type_info[:format] == "uuid" && type_allows_blank?(config)) # A singleton type (TrueClass/FalseClass) constrains the value via enum; nil joins it when nullable. prop[:enum] = nullable ? type_info[:enum] + [nil] : type_info[:enum] if type_info[:enum] end end |
.blank_default_rejected?(config) ⇒ Boolean
Whether this field's own checks would reject the blank/empty literal value its default supplies — THE single definition of "can this default relax the field", read both when judging the default's usability and when deciding requiredness (an omitted call resolves the default, so a rejected one cannot be omitted). Two checks govern blankness and either can be the only one present, so both are asked, each against the value IT rejects:
* a presence validator rejects a BLANK value (`presence_blank?`) — `presence: true` does,
absent/`presence: false` does not, `presence: { allow_blank: true }` accepts it (`allow_nil`
alone doesn't help a non-nil blank like ""/{}/[]);
* `allow_empty: false`'s own check rejects an EMPTY one (`empty_default?`), which is a different
value set: a whitespace-only String default is blank but not empty, and passes.
A Proc default is unknowable at declaration (usable_default? settles it before reaching here) and a non-applied subfield default supplies nothing to reject. Gates are deliberately not consulted, as everywhere else on the input side: a gated check is counted as if it ran.
822 823 824 825 826 827 828 829 830 |
# File 'lib/axn/internal/reflection/schema.rb', line 822 def blank_default_rejected?(config) return false unless config.respond_to?(:default) value = config.default return false if value.nil? || value.is_a?(Proc) return true if presence_blank?(value) && presence_rejects_blank?(config.validations) empty_default?(value) && config.validations.key?(Axn::Internal::FieldConfig::NON_EMPTINESS_KEY) end |
.boolean_coercion_can_flip_truthiness?(ref) ⇒ Boolean
723 724 725 726 727 728 729 730 731 732 733 734 735 |
# File 'lib/axn/internal/reflection/schema.rb', line 723 def boolean_coercion_can_flip_truthiness?(ref) type_opt = ref.validations[:type] return false unless type_opt if type_opt.is_a?(Hash) klasses = Array(type_opt[:klass]) return false if type_opt[:coerce] == false else klasses = Array(type_opt) end klasses.include?(:boolean) && klasses.any? { |k| FLIPPABLE_JSON_TYPES.include?(single_type_for(k, for_output: false)[:type]) } end |
.branch_answers_segment?(branch, segment) ⇒ Boolean
Whether ONE admissible declared branch can answer reading segment off its value.
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 |
# File 'lib/axn/internal/reflection/schema.rb', line 356 def branch_answers_segment?(branch, segment) return true if branch == :params klasses = case branch when :uuid then [String] when :boolean then [TrueClass, FalseClass] else [branch] end klasses.any? do |k| next true unless k.is_a?(Class) next true if k <= Hash judged = SEGMENT_JUDGED_SCALARS.any? { |s| k <= s } !judged || k.public_method_defined?(segment) end end |
.build_input(field_configs, subfield_configs = [], resolved: nil, klass: nil) ⇒ Object
148 149 150 151 152 153 154 155 156 157 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 188 |
# File 'lib/axn/internal/reflection/schema.rb', line 148 def build_input(field_configs, subfield_configs = [], resolved: nil, klass: nil) tree = resolved&.tree || Axn::Internal::SubfieldTree.build(field_configs, Array(subfield_configs)) ann = resolved&.annotations || derive_annotations(tree.roots) properties = {} required = [] conditionals = [] field_configs.each do |config| next if EXCLUDED_FROM_INPUT_SCHEMA.include?(config.field) node = tree.roots[config.reader_as] if config.validations[:model] # Emit the generated `<field>_id` property (don't clobber an explicitly-declared one). # Its requiredness/nullability is decided in the post-pass below so it can account for an # explicit `<field>_id` sibling regardless of declaration order. id_field, id_prop = model_id_property(config) properties[id_field] ||= id_prop else prop = build_property(config) apply_nested_subfields!(prop, node, ann) properties[config.field] = prop.compact unless field_optional?(config, node.children, ann) clause = conditional_requiredness_clause(config, field_configs, node, klass) clause ? conditionals << clause : required << required_key(config.field) end end end # Second pass (after all properties exist, so it's independent of declaration order): decide each # generated model `<field>_id`'s requiredness/nullability from the model field + its explicit sibling. field_configs.select { |config| config.validations[:model] }.each do |config| children = tree.roots[config.reader_as].children apply_model_id_requiredness!(config, children, field_configs, properties, required, ann) end schema = { type: "object", properties: } schema[:allOf] = conditionals unless conditionals.empty? schema[:required] = required.uniq unless required.empty? schema end |
.build_input_for(klass) ⇒ Object
Subfields nest recursively: a dotted on: path, a subfield of a subfield, and a dotted field
name all become nested object properties keyed by wire key (SubfieldTree resolves reader
aliases and dotted segments once, up front). A STRUCTURAL EXCLUSION remains: a deep subfield
whose chain passes through a model: parent (the client sends <field>_id, not the object) or
a non-object parent (type: Array, a mixed union) has no JSON-object representation, so it's
omitted — surfaced via dropped_deep_subfields / the input_schema warning. A depth-1 subfield
under such a parent is silently omitted (the parent keeps its declared type), as ever.
resolved: accepts a prebuilt ResolvedSubfields artifact (the per-class cache) so callers on
a repeated path skip the tree build + annotation derivation; it must have been built from the
same configs. Without it, both are computed fresh — the standalone entry point is unchanged.
The inbound projection OF A CLASS. The one place build_input's argument list is assembled from a
class, so the reflected reader and the setup-time validator cannot drift into building two different
schemas from the same declaration.
144 145 146 |
# File 'lib/axn/internal/reflection/schema.rb', line 144 def build_input_for(klass) build_input(klass.internal_field_configs, klass.subfield_configs, resolved: klass._resolved_subfields, klass:) end |
.build_output(field_configs) ⇒ Object
Every exposed field is always present in the serialized output: Values.serialize_exposed iterates
every outbound config and emits its key (value nil when unset). JSON Schema required means
property PRESENCE, not non-nullness, so every exposed field is required; nullability is carried
by the property type ("null").
1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 |
# File 'lib/axn/internal/reflection/schema.rb', line 1105 def build_output(field_configs) properties = {} required = [] field_configs.each do |config| properties[config.field] = build_property(config, for_output: true).compact required << required_key(config.field) end schema = { type: "object", properties: } schema[:required] = required.uniq unless required.empty? schema end |
.build_property(config, for_output: false, subfield: false) ⇒ Object
1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 |
# File 'lib/axn/internal/reflection/schema.rb', line 1178 def build_property(config, for_output: false, subfield: false) prop = {} # `#description` is beyond the documented member contract (see declared_attribute). description = declared_attribute(config, :description) prop[:description] = description if description # OUTPUT safety runs the other direction from input: the property must admit a SUPERSET of # what the serializer can emit. A closed outbound gate skips EVERY validator (not just # presence), so the exposed value can be anything the action assigned — no type/format/enum/ # default is assertable. Leave the property untyped (description only): untyped is the only # superset of an unconstrained value. Mirrors the module's output doctrine of leaving a value # untyped rather than asserting a type the serialized value could contradict. return prop if for_output && conditionally_gated?(config) # OUTPUT-EFFECTIVE validations (see effective_validations, the one derivation of them): everything # below reads the config through that subset, so a per-validator gate drops the same entry here as # in the plan every property-name rule is charged against. Rebuild the config only when an entry # actually drops, judged against the SAME read of `validations` the reduction was given — a # caller-supplied member's reader may mint a fresh Hash per read, so comparing against a second read # would rebuild every config (and a duck-typed member answers no `with` at all). declared = config.validations effective = effective_validations(declared, for_output:) config = config.with(validations: effective) unless effective.equal?(declared) type_info = json_type_for(config.validations, for_output:) nullable = nil_allowed?(config) apply_type_info!(prop, type_info, config, nullable:) declared_default = declared_attribute(config, :default) if !declared_default.nil? && !declared_default.is_a?(Proc) # Only a truthy subfield default is applied at runtime, so a falsey `default: false` subfield # must not advertise a default the runtime never applies. Top-level defaults apply by key-presence. emit_default = subfield ? config.applied_default? : true prop[:default] = normalize_schema_literal(declared_default) if emit_default end if (inclusion = config.validations[:inclusion]) enum_values = inclusion_enum_values(inclusion) prop[:enum] = enum_for_inclusion(enum_values, nullable:) if enum_values end apply_structured_schema!(prop, config, for_output:) # LAST, because the floor's KEY is chosen from the property's type (`minItems`/`minProperties`/ # `minLength`) and a shape block is what establishes that type: a custom class or module carrying one # holds the permissive fallback until `apply_structured_schema!` rewrites it to `object`. Deriving # the key any earlier reads an intermediate type and lands the floor under a key that cannot express # it. Nothing above depends on the constraint already being there. apply_size_constraints!(prop, config) prop end |
.children_require_presence?(children, ann) ⇒ Boolean
Whether any direct child node may NOT be omitted from the parent object — a read of each child's own precomputed annotation, never a fresh descent into its subtree.
542 543 544 |
# File 'lib/axn/internal/reflection/schema.rb', line 542 def children_require_presence?(children, ann) children.values.any? { |node| ann[node].required } end |
.condition_reference(rule, field_configs) ⇒ Object
The declared top-level inbound field a Symbol condition reads: an exact reader-name match,
or — for a ?-suffixed Symbol — the boolean field whose generated predicate alias it names.
The condition reads the READER; the emitted schema keys by the field's WIRE key.
689 690 691 692 693 694 695 696 697 |
# File 'lib/axn/internal/reflection/schema.rb', line 689 def condition_reference(rule, field_configs) name = rule.to_s exact = field_configs.find { |c| c.reader_as.to_s == name } return exact if exact return nil unless name.end_with?("?") base = name.delete_suffix("?") field_configs.find { |c| c.reader_as.to_s == base && c.boolean? } end |
.conditional_requiredness_clause(config, field_configs, node, klass) ⇒ Object
An exact JSON Schema conditional for a gated-but-otherwise-required top-level field whose
single Symbol condition references a declared sibling field. Ruby truthiness on a JSON value
is precisely "present, and neither false nor null", so the emitted clause matches the runtime
gate exactly. Returns nil — fall back to unconditional required, the static-maximal safe
direction — unless EVERY guard holds:
* exactly one gate (if: XOR unless:), and its rule is a Symbol;
* the Symbol resolves to a declared top-level inbound field's reader (condition_reference);
* the referenced field carries no default: and no preprocess: (either can make the settled
runtime value diverge from what the caller sent, flipping the gate relative to the wire)
and is not model:-routed (lookup success isn't wire-expressible) nor schema-excluded;
* for an unless: gate, the referenced field's type can't admit boolean coercion of a
schema-admissible wire value coerce_boolean maps to false — a falsy STRING or the integer 0
(boolean_coercion_can_flip_truthiness?). Coercion only flips a truthy wire value to falsey:
for an if: gate that direction keeps the emitted `then`
stricter than runtime (safe — still emitted), but for an unless: gate it opens the runtime
`else` gate the emitted clause left closed (looser than runtime — fall back);
* (a subfield default BENEATH the referenced field needs no guard: value-level defaults
resolve the child's value on the read path and never synthesize the parent — PRO-2903 —
so a wire-omitted referenced field settles nil/falsey exactly as the clause reads it;
a subfield preprocess likewise never materializes an absent root);
* the referenced reader is the FRAMEWORK-GENERATED one — a Symbol condition names a reader
method, but a user can suppress predicate generation (a pre-existing `?` method) or
redefine a plain reader after `expects`, and runtime would then evaluate the USER method
against the settled value while the clause conditions on the wire value. Verified via
source_location against the generation site (framework_generated_reader?), pure
introspection. `klass` is nil for direct build_input callers → fall back (safe direction);
* the gated field is not model:-routed and has no subfields of its own (a required
descendant unconditionally forces the field, contradicting a conditional requirement);
* no NIL-REJECTING validator entry carries a per-validator (nested) gate key — blank or not.
The clause models the DECLARATION gate; a nested gate on a nil-rejecting entry un-ties that
entry from it (AM's measured per-key merge): a blank same-key override un-gates the entry
(unconditionally required — clause looser than runtime), and a non-blank nested gate ties it
to a different condition (also inexact). Nil-TOLERANT nested-gated entries are harmless.
641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 |
# File 'lib/axn/internal/reflection/schema.rb', line 641 def conditional_requiredness_clause(config, field_configs, node, klass) return nil if config.validations[:model] || node.children.any? gates = config.validations.slice(*Internal::FieldConfig::CONDITIONAL_GATE_KEYS) return nil unless gates.size == 1 # The emitted clause conditions requiredness on exactly this DECLARATION gate — exact only if # every nil-rejecting validator entry inherits that gate unmodified. A nested gate KEY on such an # entry breaks that (AM's measured per-key merge, fields.rb#validator_gate_open?): a BLANK # same-key nested override un-gates the entry, making it unconditionally required (clause looser # than runtime), while a NON-blank nested gate ties the entry to a DIFFERENT condition than the # clause emits (also inexact). Either way fall back to unconditional required (the static-maximal # safe direction). Nil-TOLERANT entries never reject an omitted value, so a nested gate on them # can't affect requiredness — don't fall back on those. entries = Axn::Validation::Base.validator_entries(config.validations) shared = (config) return nil if entries.any? { |key, opt| !nil_tolerant_validation?(key, opt, shared) && entry_mentions_gate_key?(opt) } rule = gates.values.first return nil unless rule.is_a?(Symbol) ref = condition_reference(rule, field_configs) return nil unless ref return nil if ref.validations[:model] || !ref.default.nil? || ref.preprocess return nil if EXCLUDED_FROM_INPUT_SCHEMA.include?(ref.field) return nil unless framework_generated_reader?(klass, rule) # An unless: gate treated static-maximally emits `else: required`, firing only when the # referenced wire value is FALSEY. But inbound boolean coercion can flip a schema-admissible # truthy wire value ("false"/"f"/"0" as a String, or the JSON number 0) to a falsey settled # value, opening the runtime gate while the emitted `if` still reads the wire value as truthy — # so the schema would NOT require the gated field though the runtime does (looser than # runtime). For an if: gate the same flip makes the schema stricter (the emitted `then` keeps # requiring while the runtime gate closes), so only unless: must fall back to unconditional # required. return nil if gates.key?(:unless) && boolean_coercion_can_flip_truthiness?(ref) condition = { required: [required_key(ref.field)], properties: { ref.field => { not: { enum: [false, nil] } } }, } branch = gates.key?(:if) ? :then : :else { if: condition, branch => { required: [required_key(config.field)] } } end |
.conditionally_gated?(config) ⇒ Boolean
Whether the config's declaration carries a declaration-level if:/unless: gate — the signal
that its enforcement (NOT its shape) is conditional at runtime. Asked of a config here and of
already-read validations in shape_property_plan (which holds nothing but the reduced Hash); one
predicate, so the two cannot answer differently. The reduction never removes a declaration-level gate
key, so both spellings see the same keys.
1689 |
# File 'lib/axn/internal/reflection/schema.rb', line 1689 def conditionally_gated?(config) = gated_validations?(config.validations) |
.config_answers_segment?(config, segment) ⇒ Boolean
Whether a config's declared type admits SOME branch that can answer segment. A model:
route resolves to a record, whose method surface is never statically refutable.
375 376 377 378 379 |
# File 'lib/axn/internal/reflection/schema.rb', line 375 def config_answers_segment?(config, segment) return true if config.validations[:model] object_type_branches(config).any? { |branch| branch_answers_segment?(branch, segment) } end |
.credit_sibling_id_defaults!(node, ann) ⇒ Object
Satisfiability-only post-adjustment (runs before this node's own requiredness is computed, so the
credit propagates up every ancestor): a model-routed child that a sibling <key>_id subfield can
rescue is re-annotated non-required. The sibling's value-level default supplies the lookup token at
read time (see ContractForSubfields.resolve_model_via_id), so omitting the record still
resolves it and the record answers the subtree; the record's attributes are unknowable at
declaration, so crediting the rescue is the satisfiability doctrine. STRICT (schema) mode is
untouched — it keeps its documented stricter-than-runtime divergence for self-referential id/model
subfield pairs (apply_model_id_requiredness!'s KNOWN LIMITATION).
489 490 491 492 493 494 495 496 |
# File 'lib/axn/internal/reflection/schema.rb', line 489 def credit_sibling_id_defaults!(node, ann) node.children.each do |key, child| next if child.implicit? || !ann[child].required next unless sibling_id_rescued?(node, key, child) ann[child] = NodeAnnotation.new(required: false, nullable: ann[child].nullable) end end |
.custom_serialization?(klass, method) ⇒ Boolean
415 416 417 |
# File 'lib/axn/internal/reflection/schema.rb', line 415 def custom_serialization?(klass, method) klass.method_defined?(method) && !FRAMEWORK_SERIALIZATION_OWNERS.include?(klass.instance_method(method).owner) end |
.declared_attribute(config, name) ⇒ Object
An attribute a config may or may not carry, read tolerantly: #description and #default, enumerated at
each call site. A FieldConfig answers both and a ShapeConfig answers #description only (a member is
reader-less, so default: is rejected on one), which is what this exists for — one emission path over two
config types, plus the configs a downstream caller builds itself and hands to the public build_input.
ShapeGraph.read is the same tolerant read the declaration guards use, so both layers agree about what a
config has.
84 |
# File 'lib/axn/internal/reflection/schema.rb', line 84 def declared_attribute(config, name) = Axn::Internal::ShapeGraph.read(config, name) |
.declared_size_minimum(config) ⇒ Object
The smallest size this field's validators admit, or nil when they admit an empty value. An explicit
length: floor wins over the implicit 1 that the emptiness check and the presence check each carry —
a caller needs the tightest of them, and all three forbid empty. The floor is read by
Validation::Base's shared definition, the same one the
emptiness reconciliation judges a declaration by, so what the runtime enforces and what the schema
advertises cannot drift; a per-call (Symbol/Proc) or infinite floor is unemittable and falls through
to the presence check.
A blank-tolerant length: contributes its floor only when an empty value would be rejected ANYWAY.
Blank-tolerance on one entry says an empty value stands THAT entry aside, not that an empty value gets
through: with nothing else rejecting it the contract admits "empty or at least 3", which no floor
expresses, so emitting 3 would reject a value the contract accepts — but where a presence or emptiness
check rejects every empty value, 3 or more is all the contract admits and the floor is exact. Truthiness
decides the tolerance, not key presence: a nil-tolerance injects an explicit allow_blank: false.
A context-scoped entry contributes no floor either, for the stronger reason that it runs on NO call (Validation::Base.entry_context_scoped?), so its floor is a constraint the contract never applies. That is not the gate treatment: a GATED entry may be open on a given call, and is counted as if it were — static-maximal, which can leave the input schema stricter than a closed-gate runtime but never looser, and is the policy for every gated constraint here.
Only length: is consulted, never a size:: size is absent from KNOWN_VALIDATION_KEYS, so a
declaration carrying it raises "Unknown key(s) :size" and can never reach reflection.
1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 |
# File 'lib/axn/internal/reflection/schema.rb', line 1308 def declared_size_minimum(config) validations = config.validations # Whether an empty value can get through at all decides BOTH branches below: it is the floor of 1 a # presence/emptiness check imposes on its own, and it is what tells a blank-tolerant `length:` apart # from one whose blank-tolerance is moot. rejects_empty = empty_value_rejected?(validations) length = (validations[:length], (config)) if !entry_context_scoped?(length) && (rejects_empty || !length[:allow_blank]) declared = Axn::Validation::Base.declared_length_floor(length) return declared if Axn::Validation::Base.emittable_length_floor?(declared) end rejects_empty ? 1 : nil end |
.derive_annotations(roots, satisfiability: false) ⇒ Object
One bottom-up pass over the whole subfield tree, computed once from build_input and threaded
through every emission site below (apply_nested_subfields!/apply_children!/apply_implicit_node!/
apply_model_id_requiredness!) instead of each of them independently re-walking the subtree via
subtree_requires_presence?/required_child? — the repeated-recomputation pattern behind PR #149's
rounds-5/8/9 findings (a dropped/blocked deep shape agreeing at some sites but not others).
compare_by_identity: SubfieldTree::Node is a plain Data value, so identity (not #==/#hash on its
contents) is what distinguishes one tree position from another.
426 427 428 429 430 |
# File 'lib/axn/internal/reflection/schema.rb', line 426 def derive_annotations(roots, satisfiability: false) ann = {}.compare_by_identity roots.each_value { |node| annotate_node!(node, ann, satisfiability:) } ann end |
.drop_uuid_format(members) ⇒ Object
Strip format: "uuid" from anyOf members: a blank-tolerant uuid accepts "" at runtime, which a
strict format: uuid validator would reject (mirrors the scalar-type relaxation above).
1789 1790 1791 |
# File 'lib/axn/internal/reflection/schema.rb', line 1789 def drop_uuid_format(members) members.map { |m| m[:format] == "uuid" ? m.except(:format) : m } end |
.dropped_deep_subfields(field_configs, subfield_configs, resolved: nil) ⇒ Object
The subfield configs build_input omits from the input schema: deep configs (a dotted on:
path, a subfield of a subfield, or a dotted field name) whose chain passes through a model:
or non-object parent, so they have no JSON-object representation. They validate at runtime but
are absent from the schema; a caller can surface this otherwise-silent gap. A representable deep
chain (every explicit ancestor object-shaped) is NOT dropped — it nests in the schema.
Subfields rooted at a deliberately-excluded parent (EXCLUDED_FROM_INPUT_SCHEMA, e.g.
ambient_context) are skipped: their absence is intentional. Side-effect-free (SubfieldTree
inspects declared configs only).
resolved: accepts the per-class ResolvedSubfields cache, whose dropped was already computed
from the same tree at build time (see ResolvedSubfields.build) — reading it here is a cheap
reader, not a recomputation. Without it, both the tree and the verdict are built fresh.
202 203 204 205 206 |
# File 'lib/axn/internal/reflection/schema.rb', line 202 def dropped_deep_subfields(field_configs, subfield_configs, resolved: nil) return resolved.dropped if resolved dropped_from_deep_paths(Axn::Internal::SubfieldTree.build(field_configs, Array(subfield_configs)).deep_paths) end |
.dropped_from_deep_paths(deep_paths) ⇒ Object
The judgment over a tree's deep candidates: which of the [config, hops] pairs SubfieldTree.build
collected (a config reached through more than one hop) have no JSON-object representation. Tree
construction only COLLECTS these — whether a chain can hold JSON object properties is a question
about what this layer can EMIT, so the two public entry points (this one, and dropped_deep_subfields
for a caller that has only configs, not a built tree) both funnel through the same private judgment.
213 214 215 |
# File 'lib/axn/internal/reflection/schema.rb', line 213 def dropped_from_deep_paths(deep_paths) compute_dropped(deep_paths) end |
.effective_entry_options(entry, declaration_options) ⇒ Object
An entry's options as validates will hand them over — the declaration-wide shared options with the
entry's own merged on top, so a shared tolerance or context is judged here exactly as at runtime.
1773 |
# File 'lib/axn/internal/reflection/schema.rb', line 1773 def (entry, ) = Axn::Validation::Base.(entry, ) |
.effective_validations(validations, for_output:) ⇒ Object
THE ONE derivation of the validations a projection is BUILT from, and the reason it is a function rather
than a step inside build_property: a per-validator (nested) gate can skip an INDIVIDUAL check on a
given call (type: { klass: Integer, if: :flag } with flag falsey lets a nonblank wrong-typed value
through), so its constraint can't be promised outbound — and every rule DERIVED from what the projection
emits has to start from the same reduced view, or it describes a schema the emitter never emits. The
projection size cap charged 25,000 properties for a gated type: SomeData whose members build_property
drops before it emits anything, because "exact given the plan" says nothing when the plan's input differs.
What survives with EVERY gate closed: entries carrying a gate of their own (entry_self_gated?) drop, ungated
entries stay (a gated inclusion: alongside an ungated type: still emits the type), and
declaration-level gate keys stay too (inert to this reduction — a wholly gated outbound config is
already left untyped by its own earlier return, in both build_property and shape_property_plan).
INPUT is untouched and returns the SAME Hash: static-maximal is the safe direction there (a gate can
only relax enforcement at runtime), and identity is what lets build_property skip rebuilding a config.
1459 1460 1461 1462 1463 1464 |
# File 'lib/axn/internal/reflection/schema.rb', line 1459 def effective_validations(validations, for_output:) return validations unless for_output effective = validations.reject { |_key, opt| entry_self_gated?(opt) } effective.size == validations.size ? validations : effective end |
.empty_container?(value) ⇒ Boolean
Whether a default is an EMPTY container, decided by WHOSE empty? would answer it. Ownership is the whole
test, because it separates the two things a subclass can be: one that INHERITS the built-in's empty?
answers with Ruby's own code, so running it is safe and its empty instance is as empty as the built-in's;
one that OVERRIDES it (or carries a singleton) is caller code, which a reflection verdict must not run —
and not recognizing it is also what matches the runtime, since that same override is what the emptiness
check will ask. Anything else — a lazy collection, an arbitrary object — is unrecognized for the same
reason, so no empty? of a caller's writing is ever dispatched here.
The owner read is bound (NativeMethods.method_owner); the call that follows it needs no guard, because
the implementation it dispatches is the one whose owner was just established.
876 877 878 879 880 881 |
# File 'lib/axn/internal/reflection/schema.rb', line 876 def empty_container?(value) owner = Axn::Internal::NativeMethods.method_owner(value, :empty?) return false unless owner && native_empty_owner?(owner) value.empty? end |
.empty_default?(value) ⇒ Boolean
Whether a default is EMPTY — the question allow_empty: false's own check asks of a value, which is
not blankness: a whitespace-only String is blank but not empty, and false has no empty state at all.
903 |
# File 'lib/axn/internal/reflection/schema.rb', line 903 def empty_default?(value) = empty_container?(value) |
.empty_value_rejected?(validations) ⇒ Boolean
Whether an empty value is rejected by something OTHER than the author's own length: — either
allow_empty: false's own check or a live presence check (every empty value is blank, so a presence
check that rejects blank rejects every empty value). THE question "can an empty value get through
here", which decides both the fallback floor of 1 and whether a blank-tolerant length: still
contributes its floor.
849 850 851 852 853 |
# File 'lib/axn/internal/reflection/schema.rb', line 849 def empty_value_rejected?(validations) return true if validations.key?(Axn::Internal::FieldConfig::NON_EMPTINESS_KEY) presence_rejects_blank?(validations) end |
.entry_context_scoped?(opt) ⇒ Boolean
1768 |
# File 'lib/axn/internal/reflection/schema.rb', line 1768 def entry_context_scoped?(opt) = Axn::Validation::Base.entry_context_scoped?(opt) |
.entry_effective_gate_keys(entry_opts, decl_gates) ⇒ Object
Which gate keys EFFECTIVELY gate a single validator entry, given the declaration-level gates
(decl_gates = the sliced :if/:unless off the whole declaration, already blank-canonicalized).
Owned by Validation::Base so the declaration-time nil-skip push-down judges runtime skippability
identically; structural (never evaluates a condition), which is what keeps reflection
side-effect-free.
1718 |
# File 'lib/axn/internal/reflection/schema.rb', line 1718 def entry_effective_gate_keys(entry_opts, decl_gates) = Axn::Validation::Base.entry_effective_gate_keys(entry_opts, decl_gates) |
.entry_mentions_gate_key?(opt) ⇒ Boolean
Whether a single validator ENTRY's options MENTION a per-validator gate key at all — blank or not (contrast entry_self_gated?, which requires a NON-blank value). A blank nested gate is not inert for the declaration-level requiredness clause: per AM's measured per-key merge (fields.rb#validator_gate_open?), a blank nested same-key value OVERRIDES and drops the shared (declaration) gate for that key before AM ignores it — un-gating the entry. So an entry that mentions ANY gate key no longer inherits the declaration gate verbatim.
1707 1708 1709 1710 1711 |
# File 'lib/axn/internal/reflection/schema.rb', line 1707 def entry_mentions_gate_key?(opt) return false unless opt.is_a?(Hash) Internal::FieldConfig::CONDITIONAL_GATE_KEYS.any? { |k| opt.key?(k) } end |
.entry_self_gated?(opt) ⇒ Boolean
Whether a single validator ENTRY carries a real per-validator (nested) if:/unless: gate — one that can
skip that entry alone (e.g. presence: { if: -> { ... } }, type: { klass: Integer, if: :flag }).
Owned by Validation::Base so the emptiness axis's deferral test and this reasoning judge one entry the
same way.
1699 |
# File 'lib/axn/internal/reflection/schema.rb', line 1699 def entry_self_gated?(opt) = Axn::Validation::Base.entry_self_gated?(opt) |
.enum_for_inclusion(enum_values, nullable:) ⇒ Object
The enum: member list for an inclusion set. nullable (nil_allowed?) is the runtime truth: when
false, a literal nil member is dropped (an explicit nil is rejected there); when true, nil is
added if not already present.
1159 1160 1161 1162 1163 1164 1165 |
# File 'lib/axn/internal/reflection/schema.rb', line 1159 def enum_for_inclusion(enum_values, nullable:) members = normalize_schema_literal(enum_values) return members.compact unless nullable # Identity check, not include?/==: an enum member with a custom `==` must not run during reflection. members.any? { |m| m.equal?(nil) } ? members : members + [nil] end |
.enum_scalar_type(value) ⇒ Object
1671 1672 1673 1674 1675 1676 1677 |
# File 'lib/axn/internal/reflection/schema.rb', line 1671 def enum_scalar_type(value) return "string" if value.is_a?(String) return "integer" if value.is_a?(Integer) return "number" if value.is_a?(Float) nil end |
.field_optional?(config, children, ann, satisfiability: false) ⇒ Boolean
A field is absent from required when a declared signal makes it omittable.
588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 |
# File 'lib/axn/internal/reflection/schema.rb', line 588 def field_optional?(config, children, ann, satisfiability: false) has_required_child = required_child?(config, children, ann) # A usable default on the PARENT materializes it (with its declared contents) before validation, # so it may always be omitted — its own default, not its subfields, decides. (A default whose # contents fail a child's validators is a separate, narrow divergence handled by usable_default?.) return true if usable_default?(config, subfield: false, satisfiability:) # The parent's own nil-tolerance (optional:/allow_nil:) only makes it omittable when no required # child would be stranded — so it must be checked AFTER the required-child test, not ahead of it. return true if nil_tolerance_rescues_absence?(config, satisfiability:) && !has_required_child # No parent-level omission signal remains. A subfield default resolves only the CHILD's value on # the read path (ContractForSubfields.resolve_value) — it never synthesizes the parent — so a # descendant default cannot rescue the parent's own omission. The parent's requiredness is decided # by its OWN signals (own default / own nil-tolerance, above) plus required-child stranding; a # child default fixes the child's nil, not the parent's own presence/blank obligation. false end |
.framework_generated_reader?(klass, rule_name) ⇒ Boolean
Whether the method a Symbol condition names still resolves to the reader Axn generated (not a
user method that would evaluate against the settled value instead of the wire value). The
generation site is recorded on Contract::GENERATED_READER_SOURCE_PATH; a generated reader —
and a boolean predicate alias, which shares the aliased definition's source_location — reports
that file, while a user def reports the declaring file. Pure introspection, side-effect-free.
742 743 744 745 746 |
# File 'lib/axn/internal/reflection/schema.rb', line 742 def framework_generated_reader?(klass, rule_name) return false unless klass.respond_to?(:method_defined?) && klass.method_defined?(rule_name) klass.instance_method(rule_name).source_location&.first == Axn::Core::Contract::GENERATED_READER_SOURCE_PATH end |
.gated_validations?(validations) ⇒ Boolean
1691 1692 1693 |
# File 'lib/axn/internal/reflection/schema.rb', line 1691 def gated_validations?(validations) Internal::FieldConfig::CONDITIONAL_GATE_KEYS.any? { |k| validations.key?(k) } end |
.inclusion_enum_values(inclusion) ⇒ Object
The literal membership set of an inclusion: validator, whether declared as the hash long form
({ in: [...] } / { within: [...] }) or the equivalent bare-Array shorthand (inclusion: %w[a b c]).
The two enforce the same set at runtime, so reflection treats them identically (PRO-2944). Exact
Array only (instance_of?, not is_a?): an Array subclass could override the map/each the enum and
type inference downstream depend on, and reflection must never run user code — a subclass set (or a
dynamic Symbol/Proc source) simply reflects no enum (returns nil).
1173 1174 1175 1176 |
# File 'lib/axn/internal/reflection/schema.rb', line 1173 def inclusion_enum_values(inclusion) values = inclusion.is_a?(Hash) ? (inclusion[:in] || inclusion[:within]) : inclusion values if values.instance_of?(Array) end |
.items_schema_for(of_validations, for_output: false) ⇒ Object
1494 1495 1496 1497 1498 1499 1500 1501 |
# File 'lib/axn/internal/reflection/schema.rb', line 1494 def items_schema_for(of_validations, for_output: false) klasses = Array(of_validations[:klass]) if klasses.size == 1 single_items_schema(klasses.first, for_output:) else { anyOf: klasses.map { |k| single_items_schema(k, for_output:) } } end end |
.json_type_for(validations, for_output: false) ⇒ Object
1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 |
# File 'lib/axn/internal/reflection/schema.rb', line 1640 def json_type_for(validations, for_output: false) if validations[:type] type_opt = validations[:type] klass = type_opt.is_a?(Hash) ? type_opt[:klass] : type_opt type_hashes = Array(klass).map { |k| single_type_for(k, for_output:) }.uniq return type_hashes.first if type_hashes.size == 1 return { anyOf: type_hashes } end if validations[:inclusion] enum_values = inclusion_enum_values(validations[:inclusion]) if enum_values&.any? types = enum_values.map { |v| enum_scalar_type(v) }.uniq return { type: types.first } if types.size == 1 && types.first # mixed (or unrecognized) value types → let `enum` constrain; emit no `type` return {} end end if validations[:numericality] numericality = validations[:numericality] return { type: "integer" } if numericality.is_a?(Hash) && numericality[:only_integer] return { type: "number" } end {} end |
.member_keyed_object_type?(klass) ⇒ Boolean
398 399 400 401 402 403 404 405 406 407 408 |
# File 'lib/axn/internal/reflection/schema.rb', line 398 def member_keyed_object_type?(klass) return true if klass == :params return false unless klass.is_a?(Class) return true if klass == Hash return false unless klass < Data || klass < Struct # A Data/Struct serializes member-keyed via its built-in to_h — unless it carries a CUSTOM as_json # OR a custom to_h, either of which serialize_value would follow instead (as_json first) and which # may emit a scalar/array/differently-keyed hash. !custom_serialization?(klass, :as_json) && !custom_serialization?(klass, :to_h) end |
.member_name(member) ⇒ Object
A member's NAME, or nil when it has none. Even #field is read tolerantly, and skipped rather than
raised on: a DECLARED member always has one (the walk rejects a nameless member and stores a Symbol), so
what this tolerance is for is the configs a caller builds itself and hands to the public build_input.
89 90 91 92 |
# File 'lib/axn/internal/reflection/schema.rb', line 89 def member_name(member) name = Axn::Internal::ShapeGraph.fetch(member, :field) Axn::Internal::ShapeGraph.missing?(name) ? nil : name end |
.member_properties(members, for_output:) ⇒ Object
A DECLARED member's field is already the Symbol the declaration walk judged it under (ShapeConfig
normalizes, and the walk canonicalizes a duck-typed member's name once, beside the duplicate check).
It is still symbolized here because build_input is public: a config a downstream caller built itself
may carry a raw name, and every other schema property key is a Symbol (top-level config.field,
symbolized wire keys) — so this keeps a string-named member colliding with a dotted/explicit subfield
(bar.baz) resolving to the one :bar property that every downstream lookup (apply_implicit_node!'s
existing, explicit-child overwrite) already keys by symbol, not a String duplicate alongside it.
required renders the SAME Symbol rather than converting the name a second time: two conversions of
one caller object are two answers it can give, and a name that gave them differently would list a
required property this method never emitted.
1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 |
# File 'lib/axn/internal/reflection/schema.rb', line 1525 def member_properties(members, for_output:) props = {} required = [] named_members(members).each do |m, name| key = name.to_sym props[key] = build_property(m, for_output:).compact # On OUTPUT, a member whose presence obligation can be gated off — either wholesale by a # declaration-level gate, or because every nil-rejecting entry is nil-tolerant or covered by a # per-validator (nested) gate — can legitimately be skipped or emitted without a value by a # closed gate (the serializer emits no key, or a nil/blank one, for it). requiredness_conditionally_relaxable? # (superset of conditionally_gated?) subsumes both cases, so requiredness is dropped along with # (already-handled) gated constraints. INPUT stays static-maximal (a client is still expected to # send the member) — stricter, and safe. required << key.to_s unless optional_for_schema?(m) || (for_output && requiredness_conditionally_relaxable?(m)) end [props, required] end |
.model_id_property(config) ⇒ Object
Returns [id_field_symbol, prop_hash] for a model: config. No type constraint: find/custom
finders accept any nonblank PK token, and inferring the real PK type would require a DB load.
1545 1546 1547 1548 1549 1550 1551 1552 |
# File 'lib/axn/internal/reflection/schema.rb', line 1545 def model_id_property(config) model_opts = config.validations[:model] klass = model_opts[:klass] klass_name = klass.is_a?(Class) ? klass.name : klass.to_s id_field = Axn::Internal::FieldConfig.model_id_key(config.field) prop = { description: config.description || "ID of the #{klass_name} record" } [id_field, prop.compact] end |
.named_members(members) ⇒ Object
The members of a shape that actually name a property, paired with that name.
Captured through the shared seam rather than iterated directly: Array(...) preserves a caller's
Array SUBCLASS and then dispatches its filter_map, so a list answering that differently from each
made reflection disagree with the declaration guard and the runtime validator — which both capture with
each — about which members exist. One owned Array, three consumers.
126 127 128 |
# File 'lib/axn/internal/reflection/schema.rb', line 126 def named_members(members) Axn::Internal::ShapeGraph.capture(members).filter_map { |m| (name = member_name(m)) && [m, name] } end |
.native_empty_owner?(owner) ⇒ Boolean
883 884 885 886 887 888 889 890 |
# File 'lib/axn/internal/reflection/schema.rb', line 883 def native_empty_owner?(owner) return true if EMPTY_CONTAINER_CLASSES.any? { |klass| klass.equal?(owner) } return true if defined?(Set) && ::Set.equal?(owner) # The rendered name is a Ruby-made String (the bound `Module#to_s`), so comparing it dispatches String's # own `==` whatever the owner is. Axn::Internal::ClassName.of_module(owner) == PARAMS_CLASS_NAME end |
.nestable_as_object?(config) ⇒ Boolean
ALL admissible branches are object-shaped — so the subfields may nest as properties without
rejecting a valid non-object branch. A mixed union (type: [Hash, Array]) is NOT nestable: at
runtime the subfield can be read from the Array branch too (e.g. Array#length), so forcing
type: object would disallow a valid array input.
298 299 300 |
# File 'lib/axn/internal/reflection/schema.rb', line 298 def nestable_as_object?(config) object_type_branches(config).all? { |k| [Hash, :params].include?(k) } end |
.nil_accepted?(config) ⇒ Boolean
Whether the field's validators, taken together, permit a nil/omitted value — the one question
requiredness and nullability turn on, owned by Validation::Base so a field config's own
optional? answers it identically.
1682 |
# File 'lib/axn/internal/reflection/schema.rb', line 1682 def nil_accepted?(config) = Axn::Validation::Base.nil_accepted?(config.validations) |
.nil_allowed?(config) ⇒ Boolean
1775 1776 1777 |
# File 'lib/axn/internal/reflection/schema.rb', line 1775 def nil_allowed?(config) nil_tolerance_rescues_absence?(config) end |
.nil_tolerance_rescues_absence?(config, satisfiability: false) ⇒ Boolean
Whether this config's nil-tolerance actually rescues an ABSENT value. It does not when the field declares a literal default its own blankness checks reject: axn resolves a declared default for a nil value however it arrived — an omitted key or an explicit null — so the validators see that default, never nil, and the tolerance is never what decides the call. THE single definition, so requiredness and nullability (which the same resolution governs) cannot disagree.
Satisfiability mode resolves toward satisfiable and ignores the veto, matching the Proc-default rule: a caller who SUPPLIES a value still has a working contract, so a dead default is no reason to reject the declaration.
768 769 770 771 772 |
# File 'lib/axn/internal/reflection/schema.rb', line 768 def nil_tolerance_rescues_absence?(config, satisfiability: false) return false unless nil_accepted?(config) satisfiability || !blank_default_rejected?(config) end |
.nil_tolerant_validation?(key, opt, declaration_options) ⇒ Boolean
1766 |
# File 'lib/axn/internal/reflection/schema.rb', line 1766 def nil_tolerant_validation?(key, opt, ) = Axn::Validation::Base.nil_tolerant_validation?(key, opt, ) |
.node_configs_block_nesting?(configs) ⇒ Boolean
Whether the configs declared at a subfield node forbid nesting its children as object properties:
a model: route (the client sends <field>_id, not the object) or a non-nestable type (a
non-object type or a mixed union) on ANY config. Single source of truth for the drop pass
(blocking_ancestor?, via path_blocked?) and emission (apply_nested_subfields!), so the two never
disagree on which deep structure is representable — a node the tree drops from is never re-nested
in the schema. Every route is enforced at runtime, so any one non-nestable route defeats nesting.
308 309 310 |
# File 'lib/axn/internal/reflection/schema.rb', line 308 def node_configs_block_nesting?(configs) configs.any? { |c| c.validations[:model] || !nestable_as_object?(c) } end |
.node_optional?(node, ann, configs = node.configs, satisfiability: false) ⇒ Boolean
Whether a node may be absent from its parent object. An implicit node (a dotted-path
intermediate with no declaration of its own) is omittable exactly when nothing beneath it
requires presence. An explicit node follows the single-level rule at every depth: a usable
default always rescues omission (declaration allows a default only when on: names a top-level
reader, but a dotted field NAME can land that defaulted config on a deeper node — honored here
either way; a default whose contents fail a child's validators is the same accepted divergence
as at the top level); otherwise it must tolerate nil AND strand no required descendant — a nil
node yields every descendant absent (PRO-2857), so a nil-tolerant node with a required subtree is
NOT omittable (reflected required/non-nullable, matching runtime). With multiple configs at one node
(the same wire path declared via two routes) runtime enforces all of them, so the node is
omittable only if every config is. configs defaults to the whole node but may be a subset: a
merged node's model and non-model routes emit separate properties (<leaf>_id vs the object),
each required per its own routes' configs, not the node as a whole.
565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 |
# File 'lib/axn/internal/reflection/schema.rb', line 565 def node_optional?(node, ann, configs = node.configs, satisfiability: false) return !subtree_requires_presence?(node, ann) if node.implicit? # Satisfiability doctrine: a default on ANY of the node's OWN configs (node.configs — the FULL # set, not the possibly-subset `configs` param) resolves the SHARED value at this node on the # read path, so it rescues omission for every route reading it. Each sibling route then validates # against that resolved value — being optimistic that the default satisfies each sibling's # validator is the satisfiability doctrine (rejection is reserved for provably dead declarations). # Gated on satisfiability so strict schema mode stays byte-identical to the per-config rule below. return true if satisfiability && node.configs.any? { |c| usable_default?(c, subfield: true, satisfiability: true) } configs.all? do |c| usable_default?(c, subfield: true, satisfiability:) || (nil_tolerance_rescues_absence?(c, satisfiability:) && !subtree_requires_presence?(node, ann)) end end |
.normalize_scalar_literal(value) ⇒ Object
A literal the serializer refuses outright — a non-finite default: Float::INFINITY, which no JSON
default could carry — is reported exactly as declared. Reflection describes a declaration and must
never raise on user data, and a reflected literal makes no encodability promise; serialize_exposed's
output, which does make one, is where that refusal belongs.
1150 1151 1152 1153 1154 |
# File 'lib/axn/internal/reflection/schema.rb', line 1150 def normalize_scalar_literal(value) Values.serialize_value(value) rescue Axn::Extensions::Serialization::UnserializableValue value end |
.normalize_schema_literal(value) ⇒ Object
Deep-copy a reflected literal (a default: value or an inclusion enum member) and normalize any
leaf whose JSON wire form differs from its Ruby form — Time/DateTime/Date → iso8601 String,
Symbol → String, non-Integer/Float Numeric (BigDecimal/Rational) → Float — so the emitted
default/enum matches the property's advertised type. Scalar wire coercion is delegated to
Values.serialize_value (the single source of truth for it), so the two never drift. Mutable
String leaves are duped so a consumer mutating the returned schema can't reach the stored
contract; an unrecognized object is left as-is (schema literals are already simple values,
so this deliberately does NOT follow Values.serialize_value's as_json/to_h coercion).
1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 |
# File 'lib/axn/internal/reflection/schema.rb', line 1127 def normalize_schema_literal(value) # Only EXACT built-in containers are traversed/duped (instance_of?, not is_a?): an Array/Hash/ # String SUBCLASS could override map/each_with_object/dup with user code, and reflection must stay # side-effect-free — so a subclass (like any other unrecognized object) is left opaque. if value.instance_of?(Hash) # Dup mutable String keys too (leaving them shared would let a consumer mutating a returned key # in place corrupt FieldConfig#default). value.each_with_object({}) { |(k, v), h| h[k.instance_of?(String) ? k.dup : k] = normalize_schema_literal(v) } elsif value.instance_of?(Array) value.map { |v| normalize_schema_literal(v) } elsif value.instance_of?(String) value.dup elsif value.is_a?(Symbol) || value.is_a?(Time) || value.is_a?(Date) || value.is_a?(Numeric) normalize_scalar_literal(value) else value end end |
.object_shaped?(config) ⇒ Boolean
Whether a field's declared type can be represented as a JSON object (so its subfields can nest
as object properties): Hash, :params, or untyped. A type: Array (or other non-object) parent
is not — its subfields are extracted differently at runtime and have no object-property shape.
ANY admissible branch is object-shaped (Hash/:params/untyped) — so runtime's {} synthesis from
subfield defaults can satisfy the parent type ({} is a Hash, matching an object branch).
281 282 283 |
# File 'lib/axn/internal/reflection/schema.rb', line 281 def object_shaped?(config) object_type_branches(config).any? { |k| [Hash, :params].include?(k) } end |
.object_type_branches(config) ⇒ Object
323 324 325 326 327 328 |
# File 'lib/axn/internal/reflection/schema.rb', line 323 def object_type_branches(config) type_opt = config.validations[:type] return [Hash] unless type_opt # untyped parent — object-shaped for both any?/all? Array(type_opt.is_a?(Hash) ? type_opt[:klass] : type_opt) end |
.object_typed_element?(klass) ⇒ Boolean
Whether an element type is an OBJECT on the wire a client sends (input): Hash/:params/Data/Struct.
1487 1488 1489 1490 1491 1492 |
# File 'lib/axn/internal/reflection/schema.rb', line 1487 def object_typed_element?(klass) return true if klass == :params return false unless klass.is_a?(Class) klass <= Hash || klass < Data || klass < Struct end |
.optional_for_schema?(config, subfield: false, satisfiability: false) ⇒ Boolean
Optional (client may omit) iff a usable default exists, or — with no usable default — the
validators tolerate a nil/omitted value. Top-level exposes requiredness is NOT decided here:
build_output marks every top-level exposed key required directly (the serializer always emits
them). This method reaches a for_output config only for a nested shape member, which is
serialized from the actual value and so honors its own optional:/allow_nil:/default:.
753 754 755 756 757 |
# File 'lib/axn/internal/reflection/schema.rb', line 753 def optional_for_schema?(config, subfield: false, satisfiability: false) return true if usable_default?(config, subfield:, satisfiability:) nil_tolerance_rescues_absence?(config, satisfiability:) end |
.path_blocked?(hops) ⇒ Boolean
Walk a deep config's ancestor chain hop by hop, carrying the shape members an implicit hop merged
into so a deeper implicit hop can test their OWN nested shape members (a member-of-a-member).
carried is the object-shaped member configs the current node stands in for (empty for a real
node or a fresh implicit intermediate that claimed no shape member).
Public: PropertyNames.emitted_configs asks this at EVERY depth (not just the deep configs compute_dropped reports), because the emitter blocks a property at whichever ancestor blocks it — so property attribution needs the same per-hop answer, not a second predicate that could drift.
231 232 233 234 235 236 237 238 239 |
# File 'lib/axn/internal/reflection/schema.rb', line 231 def path_blocked?(hops) carried = [] hops.each do |node, key| return true if blocking_ancestor?(node, key, carried) carried = merged_shape_members(node, key, carried) end false end |
.presence_blank?(value) ⇒ Boolean
A default value ActiveModel's presence validator treats as blank (and so rejects): false, a
whitespace-only String, or an empty container. (nil is handled by the caller.)
894 895 896 897 898 899 |
# File 'lib/axn/internal/reflection/schema.rb', line 894 def presence_blank?(value) return true if value.equal?(false) return value.strip.empty? if value.instance_of?(String) empty_container?(value) end |
.presence_rejects_blank?(validations) ⇒ Boolean
Whether an active presence: check here rejects every blank value: one is declared, it is not
blank-tolerant, and it is not context-scoped (an entry that runs on no call rejects nothing). THE
single definition, read by the blank-default judgment and by the size-floor emission. A truthy
non-Hash entry carries no tolerance, so it rejects blank.
836 837 838 839 840 841 842 |
# File 'lib/axn/internal/reflection/schema.rb', line 836 def presence_rejects_blank?(validations) presence = validations[:presence] return false unless presence opts = (presence, validations.slice(*Axn::Validation::Base.shared_validation_option_keys)) !opts[:allow_blank] && !entry_context_scoped?(opts) end |
.property_representative(configs) ⇒ Object
The config a subfield node's own object property is BUILT from: the first route that is not a model:
one (a model route emits <leaf>_id in place of the object, so it shapes no object property). Nil at a
pure-model node, which emits no object property at all.
One owner for three readers, because each of them has to name the SAME config: apply_children!, which
emits the property; annotate_node!, which decides its nullability; and the projection size cap, which
charges that config's shape and must charge no other — a second route to one wire path is enforced at
runtime but its shape:/of: is never emitted, so charging it rejected a contract over a schema it
does not have.
321 |
# File 'lib/axn/internal/reflection/schema.rb', line 321 def property_representative(configs) = configs.reject { |c| c.validations[:model] }.first |
.reject_null!(prop) ⇒ Object
Forbid null on a property (a required model-id token can't be null). Strips the null branch from
an explicit type/anyOf; for the generated id property (untyped — a model PK has no fixed JSON type)
there's no branch to strip, so add an explicit not: { type: "null" } constraint.
1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 |
# File 'lib/axn/internal/reflection/schema.rb', line 1589 def reject_null!(prop) if prop[:type].is_a?(Array) non_null = prop[:type] - ["null"] prop[:type] = non_null.size == 1 ? non_null.first : non_null elsif prop[:anyOf].is_a?(Array) prop[:anyOf] = prop[:anyOf].reject { |member| member[:type] == "null" } elsif !prop.key?(:type) prop[:not] = { type: "null" } end end |
.required_child?(config, children, ann) ⇒ Boolean
Whether a nil/absent parent leaves a required nested obligation unmet — so it can't validate and the parent is neither omittable nor nullable. Single source of truth for both the parent's requiredness (field_optional?) and nullability (apply_nested_subfields!), so the two never disagree. Two sources:
* a required subfield ANYWHERE in the subtree — a nil parent yields every descendant absent
(PRO-2857), so a required grandchild is stranded exactly like a required child; OR
* a required shape (`do…end`) member WHEN the parent has its OWN applied default that
materializes it: a top-level parent's default still resolves to its materialized value (e.g.
`{}`) through the read-path reader ShapeValidator's `source:` reads, so ShapeValidator runs
against the materialized value and enforces the member — omission can't be rescued by the
parent's nil-tolerance. Counts a Proc default (materialization fires before
the Proc's value matters — the applicability hazard). A SUBFIELD default no longer triggers
this: it resolves the child's value on the read path and never synthesizes the parent, so a
nil parent short-circuits ShapeValidator regardless of any descendant default.
534 535 536 537 538 |
# File 'lib/axn/internal/reflection/schema.rb', line 534 def required_child?(config, children, ann) return true if children_require_presence?(children, ann) config.applied_default? && synthesizable?(config) && required_shape_member?(config) end |
.required_key(name) ⇒ Object
The required entry for a property keyed by name: a String holding the bytes that name is KEYED by.
Rendered from a String's own bytes rather than by dispatching its to_s, for the same reason
member_properties renders a member's entry from the one Symbol it keyed the property by: required and
properties are two readers of one name, and a name that answers them differently lists a required
property this schema never emitted — a schema no input can satisfy. Ruby stores a plain String key as a
frozen copy of its bytes, so a SINGLETON to_s on such a name diverted the required entry alone, needing
no second declaration to go wrong.
A Symbol keeps the rendering it always had, which is Ruby's own and cannot be overridden at all (a Symbol
takes no subclass instance and no singleton). So does anything else, because the property-name rules refuse
a name that renders through its own code (NativeMethods.native_name_rendering?) before any validated
projection returns — only the public build_input/build_output reach here with one.
Every site that writes a required entry goes through this, not only the two a caller's own name can reach
(a top-level inbound field and an exposed one). At the others the name has already been interned to a Symbol
by the time it arrives — SubfieldTree interns a wire segment, model_id_key builds the generated id, and a
conditional-requiredness clause is emitted only for a framework-generated reader — so this is a no-op there.
They route through it anyway so that "what String does a required entry hold" has one answer rather than
one per site, which is how the top-level pair came to disagree with properties in the first place.
114 115 116 117 118 119 |
# File 'lib/axn/internal/reflection/schema.rb', line 114 def required_key(name) case name when ::String then ::String.new(name) else name.to_s end end |
.required_shape_member?(config) ⇒ Boolean
Whether the parent's shape (do…end) block declares a member that isn't schema-optional.
583 584 585 |
# File 'lib/axn/internal/reflection/schema.rb', line 583 def required_shape_member?(config) named_members(config.validations.dig(:shape, :members)).any? { |m, _name| !optional_for_schema?(m) } end |
.requiredness_conditionally_relaxable?(config) ⇒ Boolean
Whether a config's requiredness can be RELAXED at runtime by a conditional GATE — the signal that a required-looking route can't oblige an omitted/nil ancestor to be present, because a closed gate skips the check that would otherwise reject the nil ancestor. Reasoned on EFFECTIVE gates (entry_effective_gate_keys), which model AM's measured per-key merge of the declaration gate with each entry's nested gate — so the two tiers combine exactly as at runtime without ever evaluating a condition. Relaxable iff BOTH:
* some gate exists anywhere — a declaration-level one (already blank-canonicalized) or a real
(non-blank) nested one; AND
* every NIL-REJECTING entry is effectively gated — the gate a closed runtime pass would skip is
precisely the check that rejects the nil/absent ancestor, so nothing forces it. A nil-tolerant
entry never rejects nil, so it imposes no ancestor obligation to relax.
The measured merge is what makes the corner cases correct: a declaration gate with a BLANK
same-key nested override on the lone presence check leaves it effectively UN-gated (the override
drops the shared gate, then AM ignores the blank), so an ungated nil-rejecting check still forces
the ancestor — NOT relaxable. A DISTINCT-key declaration gate (unless:) surviving alongside a
blank nested if: still gates the entry — relaxable.
The "some gate exists" conjunct is load-bearing: a STATICALLY nil-tolerant config (optional:/
allow_nil:, no gate) must NOT be relaxed. Static tolerance does not skip a required child's
validators (a nil optional parent still strands a required descendant — PRO-2857), so such a
config stays in the subset for node_optional?'s subtree-stranding test to apply; dropping it would
vacuously ([].all?) mark the node omittable and lose that test. Only a GATE — which skips the
gated check entirely when closed — genuinely relaxes requiredness. Own-level emission is
unaffected (this governs ancestor propagation only; see annotate_node!).
1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 |
# File 'lib/axn/internal/reflection/schema.rb', line 1744 def requiredness_conditionally_relaxable?(config) gate_keys = Internal::FieldConfig::CONDITIONAL_GATE_KEYS decl_gates = config.validations.slice(*gate_keys) # `entries` are the real VALIDATORS — shared options (strict:, on:, …) aren't validators and # must not be mistaken for a nil-rejecting one (see nil_accepted?/validator_entries). entries = Axn::Validation::Base.validator_entries(config.validations) some_gate = decl_gates.any? || entries.any? { |_key, opt| entry_self_gated?(opt) } return false unless some_gate shared = (config) entries.all? do |key, opt| nil_tolerant_validation?(key, opt, shared) || entry_effective_gate_keys(opt, decl_gates).any? end end |
.set_includes_nil?(opt) ⇒ Boolean
1767 |
# File 'lib/axn/internal/reflection/schema.rb', line 1767 def set_includes_nil?(opt) = Axn::Validation::Base.set_includes_nil?(opt) |
.shape_members_at(parent_configs, key) ⇒ Object
Every shape: member declared at key across parent_configs (the implicit node collides with
them). Each config is a top-level field config OR a shape-member config carried through implicit
descent; both respond to .validations and expose nested members via dig(:shape, :members).
1095 1096 1097 1098 1099 |
# File 'lib/axn/internal/reflection/schema.rb', line 1095 def shape_members_at(parent_configs, key) Array(parent_configs).flat_map do |config| named_members(config.validations.dig(:shape, :members)).filter_map { |m, name| m if name.to_sym == key } end end |
.shape_overlay_applies?(of_validations, for_output:) ⇒ Boolean
Whether a shape block should overlay object properties onto an array's items. OUTPUT: each element
must provably serialize to a member-keyed object (a plain Data/Struct/Hash of:). INPUT: the
elements must be object-typed (Hash/:params/Data/Struct) or untyped (no of: — the client sends
objects). A scalar of: (String/Integer/…) reads members off the scalar, so it is NOT overlaid.
1470 1471 1472 1473 1474 1475 1476 |
# File 'lib/axn/internal/reflection/schema.rb', line 1470 def (of_validations, for_output:) return shaped_items_serialize_to_object?(of_validations) if for_output return true unless of_validations # untyped elements: client sends objects with the shape members klasses = Array(of_validations[:klass]) klasses.any? && klasses.all? { |k| object_typed_element?(k) } end |
.shape_property_plan(config, for_output:) ⇒ Object
1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 |
# File 'lib/axn/internal/reflection/schema.rb', line 1400 def shape_property_plan(config, for_output:) # THE reason the charge and the emitter cannot start from different configs: the effective derivation # happens HERE, on the way in, so no caller can hand this a config the emitter would not have used. # `build_property` applies the same derivation before it emits, which makes the one here idempotent # (nothing left to drop) rather than a second opinion. validations = effective_validations(config.validations, for_output:) of = validations[:of] shape = validations[:shape] in_items = Array(json_type_for(validations, for_output:)[:type]).include?("array") nothing = ShapePropertyPlan.new(emitted: false, in_items:, type_schema: {}, shape:) # The same two gates `apply_structured_schema!` opens with, in the same order. A declaration with # neither `of:` nor `shape:` contributes no object properties AT ALL — not even its type's own members — # so a `Data` used purely as a `type:` names nothing, and a rule keyed on these names must not fire on # it. Likewise a wholly gated outbound config, which `build_property` leaves untyped before reaching # emission. return nothing unless of || shape return nothing if for_output && gated_validations?(validations) # An INPUT model route emits `<field>_id` in place of the field, so `apply_structured_schema!` is never # reached for one — stated here rather than only in the emitter's branch, so a consumer deriving from this # plan (the projection size cap; collision attribution) cannot charge or attribute a property the schema # names nowhere. On OUTPUT the field itself is emitted, so its shape is emitted with it. return nothing if !for_output && validations[:model] if in_items # Overlay the shape's object properties onto items only when the ELEMENTS are objects. emitted = (of, for_output:) # `items_schema_for` seeds an element type's own members whenever there is an `of:`, shape or not. return ShapePropertyPlan.new(emitted:, in_items:, shape:, type_schema: of ? items_schema_for(of, for_output:) : {}) end # Only the `elsif shape` branch emits object properties for a non-array field: `of:` without a shape on # a non-array type reaches neither branch. return nothing unless shape # A shaped object field IS an object, even when its declared type: (e.g. a Data.define subclass) isn't # in TYPE_MAP — on input unconditionally, on output only when the value serializes member-keyed. emitted = !for_output || shape_serializes_to_object?(validations) type_klass = validations.dig(:type, :klass) base = emitted && type_klass.is_a?(Class) && type_klass < Data ? type_klass.members.to_h { |m| [m, {}] } : {} # A non-array type contributes at ONE node (a multi-class `type:` reflects as `anyOf` branches of # scalar types, which name no properties), so its schema is just those properties. ShapePropertyPlan.new(emitted:, in_items:, shape:, type_schema: { properties: base }) end |
.shape_serializes_to_object?(validations) ⇒ Boolean
Whether a shaped field's value serializes to a member-keyed JSON object (so advertising object +
the shape's properties on OUTPUT matches serialize_exposed). Only asserted for types with a
language-guaranteed member-keyed serialization: :params, an untyped shape (caller supplies a
Hash), Hash, or a Data/Struct that does NOT define its OWN as_json. Values.serialize_value
follows a value's own as_json before to_h, so a Data/Struct that overrides as_json may emit
a scalar/array/differently-keyed hash — treat it (like any reader-only or custom-to_h class) as
statically unknowable and leave it untyped on output.
Takes VALIDATIONS rather than a config because its one caller (shape_property_plan) has already reduced the config to the validations the projection is built from — see effective_validations.
391 392 393 394 395 396 |
# File 'lib/axn/internal/reflection/schema.rb', line 391 def shape_serializes_to_object?(validations) type_klass = validations.dig(:type, :klass) return true if type_klass.nil? Array(type_klass).all? { |k| member_keyed_object_type?(k) } end |
.shaped_items_serialize_to_object?(of_validations) ⇒ Boolean
Whether an of: element type provably serializes to a member-keyed object (output items). Needs of:.
1479 1480 1481 1482 1483 1484 |
# File 'lib/axn/internal/reflection/schema.rb', line 1479 def shaped_items_serialize_to_object?(of_validations) return false unless of_validations klasses = Array(of_validations[:klass]) klasses.any? && klasses.all? { |k| member_keyed_object_type?(k) } end |
.shared_validation_options(config) ⇒ Object
The declaration-wide options every entry of a config rides alongside — the tier the per-entry judgments resolve against.
1762 1763 1764 |
# File 'lib/axn/internal/reflection/schema.rb', line 1762 def (config) config.validations.slice(*Axn::Validation::Base.shared_validation_option_keys) end |
.sibling_id_rescued?(parent, key, node) ⇒ Boolean
Whether a node's model route is rescued by a sibling <key>_id default — the SINGLE source of
truth for both the satisfiability annotation credit (credit_sibling_id_defaults!) and
SubfieldContradictions' per-config tolerance loop, so the two can't drift on which nodes the
id rescues. Three conjuncts:
* the node carries a `model:` route (the record it resolves answers the subtree at runtime);
* every NON-model route merged onto the node is own-level satisfiability-tolerant (a usable
default or nil-accepting) — own-level only, because the model subtree is satisfied via the
resolved record; it's the non-model route's OWN wire value the id can't supply (a pure-model
node has no non-model route, so the empty set trivially satisfies this); AND
* a sibling `<key>_id` child carries a default usable as a lookup token (usable_id_token_default?
rejects a blank literal — the model resolver blank-guards the id).
parent is the node whose children include both node (keyed by key) and the id sibling.
510 511 512 513 514 515 516 517 518 |
# File 'lib/axn/internal/reflection/schema.rb', line 510 def sibling_id_rescued?(parent, key, node) return false unless node.configs.any? { |c| c.validations[:model] } non_model = node.configs.reject { |c| c.validations[:model] } return false unless non_model.all? { |c| usable_default?(c, subfield: true, satisfiability: true) || nil_accepted?(c) } sibling = parent.children[Internal::FieldConfig.model_id_key(key)] !!sibling&.configs&.any? { |c| usable_id_token_default?(c) } end |
.single_items_schema(klass, for_output: false) ⇒ Object
1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 |
# File 'lib/axn/internal/reflection/schema.rb', line 1503 def single_items_schema(klass, for_output: false) # A Data element serializes member-keyed via to_h, so its array items reflect as objects — except # on OUTPUT when the element isn't provably member-keyed (a custom as_json/to_h serialize_value # would follow); leave those items untyped rather than promise an object. if klass.is_a?(Class) && klass < Data && (!for_output || member_keyed_object_type?(klass)) { type: "object", properties: klass.members.to_h { |m| [m, {}] } } else json_type_for({ type: klass }, for_output:) end end |
.single_type_for(klass, for_output:) ⇒ Object
1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 |
# File 'lib/axn/internal/reflection/schema.rb', line 1600 def single_type_for(klass, for_output:) return { type: "boolean" } if klass == :boolean # TypeValidator accepts only the singleton value for TrueClass/FalseClass, so constrain the schema # to it (a bare `type: "boolean"` would let a client send the other value and pass validation). return { type: "boolean", enum: [true] } if klass == TrueClass return { type: "boolean", enum: [false] } if klass == FalseClass return { type: "string", format: "uuid" } if klass == :uuid return { type: "object" } if klass == :params # A declared type that ADMITS a Complex value (`type: Numeric` or `type: Complex`, i.e. Complex is # the class or one of its ancestors) can serialize to a JSON number (real Numerics) OR a String # (Complex — Float() rejects it, so Values.serialize_value falls back to to_s). Its output wire # form isn't knowable from the declaration, so leave it UNTYPED on output rather than assert # "number" the serialized value could contradict. Input still resolves below: `Numeric` maps to # "number" (a JSON number is a real Numeric and validates), `Complex` to the permissive "string". return {} if for_output && klass.is_a?(Class) && klass >= Complex if TYPE_MAP.key?(klass) result = { type: TYPE_MAP[klass] } result[:format] = FORMAT_MAP[klass] if FORMAT_MAP.key?(klass) return result end # A Numeric subclass not in TYPE_MAP (BigDecimal, Rational, …) serializes to a JSON number # (Values.serialize_value coerces it via Float()), so reflect it as "number" rather than the # object/string fallback. Complex is the exception: Float() rejects it, so on input it drops to # the permissive "string" below (a JSON client can't send a Complex anyway; output is handled # above). return { type: "number" } if klass.is_a?(Class) && klass < Numeric && !(klass <= Complex) # Unknown class: the serialized shape is only knowable at runtime (Values.serialize_value emits # an object for an as_json/to_h value but a string for a to_s-only one), so on output leave it # UNTYPED rather than assert `object` the serialized value might contradict. On input, keep a # permissive `string` hint (a JSON client can't send a Ruby object anyway — see the reflection # docs on coercing Ruby-object input types). return {} if for_output { type: "string" } end |
.size_constraint_key_for(type) ⇒ Object
The JSON Schema floor key for an emitted type, or nil for a type with no empty state. Reads the
single-type String and the [T, "null"] nullable pair alike; "null" is never size-bearing.
1281 1282 1283 |
# File 'lib/axn/internal/reflection/schema.rb', line 1281 def size_constraint_key_for(type) Array(type).filter_map { |t| SIZE_CONSTRAINT_KEYS[t] }.first end |
.subtree_requires_presence?(node, ann) ⇒ Boolean
Whether omitting/nil-ing this node's value strands a required descendant — the transitive extension of the one-level required-child test.
548 549 550 |
# File 'lib/axn/internal/reflection/schema.rb', line 548 def subtree_requires_presence?(node, ann) children_require_presence?(node.children, ann) end |
.synthesizable?(config) ⇒ Boolean
Whether an object ({}) could stand in for this config's value: its declared type must admit an
object AND it must not be a model: route (a {} there is rejected by ModelValidator and would
be preferred by the model resolver over a caller-supplied <field>_id). required_child? uses
this to decide whether the parent's OWN applied default materializes an object that would then
enforce its required shape members.
290 291 292 |
# File 'lib/axn/internal/reflection/schema.rb', line 290 def synthesizable?(config) object_shaped?(config) && !config.validations[:model] end |
.type_allows_blank?(config) ⇒ Boolean
Whether the TYPE validator itself tolerates a blank value (type: :uuid, allow_blank: true
folds allow_blank into the type validator's options). Only the type validator's own option
matters for dropping format: "uuid" — a blank-tolerant length:/other validator doesn't make
TypeValidator accept "", so the format must stay.
1783 1784 1785 |
# File 'lib/axn/internal/reflection/schema.rb', line 1783 def type_allows_blank?(config) (config.validations[:type], (config))[:allow_blank] == true end |
.usable_default?(config, subfield:, satisfiability: false) ⇒ Boolean
A default lets the client omit the field (Axn applies it before validation). We judge usability
by declared SHAPE only — never by running the field's validators. A Proc default is unknowable at
declaration, so the two modes diverge on it (the ONLY semantic delta): strict (schema) mode
resolves toward required — the safe direction — while satisfiability mode (the declaration-rejection
detector) resolves toward satisfiable, since the Proc DOES apply at runtime and rejection is
reserved for provably dead declarations. For a subfield, only a truthy default is applied at runtime
(next unless config.default), so a falsey subfield default never counts.
An empty literal default ({}/""/[]) makes the field omittable only when nothing here would
reject the synthesized blank — asked of every check that governs blankness/emptiness
(blank_default_rejected?), since either can be the one standing between the field and an empty
value. (A blank rejected by an author's OWN size constraint — a length: floor — is a
self-contradictory contract: the same accepted divergence as a non-blank invalid default, where
the schema reflects optional though the omitted call fails at runtime.)
The emptiness check is limited to literal containers (Hash/Array/String): reflection must stay
side-effect-free, and calling empty? on an arbitrary default (e.g. an ActiveRecord::Relation or
other lazy collection) could issue a query or run user code. A non-literal default is present.
792 793 794 795 796 797 798 799 800 801 802 803 804 805 |
# File 'lib/axn/internal/reflection/schema.rb', line 792 def usable_default?(config, subfield:, satisfiability: false) # `#default` is beyond the documented member contract, so absent and nil are one answer here — both # mean "no default to relax the field with", which is what the original respond_to? guard did. value = declared_attribute(config, :default) return false if value.nil? # The governing split (PRO-2889): a Proc default is unknowable at declaration. Strict (schema) # mode resolves toward required — the safe direction — while satisfiability mode (the # declaration-rejection detector) resolves toward satisfiable: the Proc DOES apply at runtime, # and rejection is reserved for provably dead declarations. return satisfiability if value.is_a?(Proc) return false if blank_default_rejected?(config) subfield ? config.applied_default? : true end |
.usable_id_token_default?(config) ⇒ Boolean
Whether an <field>_id default can actually serve as a model LOOKUP token — the shared test
for every id-rescue site (sibling_id_rescued?, which serves both the annotation credit and the
contradictions loop, and SubfieldContradictions' model_omittable?). usable_default? judges a default for the FIELD's OWN
omission, where a blank literal ("" / {}) is usable when no presence validator rejects it — but
the model resolver blank-guards the id (Model#derive_value: return nil if id_value.blank?), so
a blank id default can never resolve a record and never rescues an omitted model. It must
therefore be satisfiability-usable AND not a blank literal. A Proc default stays optimistic
(unknowable at declaration), matching usable_default?'s satisfiability doctrine.
913 914 915 916 917 918 919 920 |
# File 'lib/axn/internal/reflection/schema.rb', line 913 def usable_id_token_default?(config) return false unless usable_default?(config, subfield: true, satisfiability: true) value = declared_attribute(config, :default) return true if value.is_a?(Proc) !presence_blank?(value) end |
.validator_entry_options(entry) ⇒ Object
1769 |
# File 'lib/axn/internal/reflection/schema.rb', line 1769 def (entry) = Axn::Validation::Base.(entry) |