axn 0.1.0.pre.alpha.5 → 0.1.0.pre.alpha.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7c950f2b3d95c1f76aa0d963531b635e08c57058a2c3e2c8d07ccd40355faa5
4
- data.tar.gz: 459ab003e2d8802c4c547ff6b89f6de3cc673e7e50072a66928d6a1b78444db7
3
+ metadata.gz: 3ff42be650685edfd89fd7ca703ea34d6f216caa0f2b1bcb1c6f1d939f5be32b
4
+ data.tar.gz: 3b99fe3510d0db9d62e159c41aa54572b709bfef8425422746ae1b57516c1fec
5
5
  SHA512:
6
- metadata.gz: b8917cfa50c90daf33a41ebc40c90e5bec9bafe926da10a70ac4b73c34d5883185569af921d8f7693dd4f0b38074d07d675ecc8d3f7b4808be4b69ef8e52c6b9
7
- data.tar.gz: 96a0838272f6f800206d2c85beae9c82f1e1ef954db7d940d856c431719870639bc4e63f1ad1883621665ac67eaa676bbc270c1072c0bf517d458e268d6790ef
6
+ metadata.gz: c41fc0bf24b4ed5ee67d4c1e43e7d64fe661800c0aac87c3096b80f50707871446a9d844ee4bc54e66992b6fa7980c7d58dcd5d1e238a566c87610b1c62dbad5
7
+ data.tar.gz: e4603c1d65cf9cd152a329faa6f53764a25b39ecc8c96129f067e3c0e5000925412e30d2801d2bc33b0be299e9c15271d4eb6f8507f2726339e97f72f1c585e6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.5.1
4
+
5
+ * [BUGFIX] The Rails engine now registers even when `axn` is required before Rails. `require "axn"` loaded `axn/rails/engine` only if `Rails` was already defined, and re-requiring axn later is a no-op — so a host whose `spec_helper.rb` requires `axn/testing/spec_helpers` (the placement we document) ahead of the `rails_helper.rb` that boots the app got no engine at all. Without it `app/actions` never received its configured `app_actions_autoload_namespace`, Rails pushed the directory at the root namespace instead, and every constant under it was unresolvable — in the test environment only, so a passing boot said nothing. Introduced in 0.1.0-alpha.5, when `axn/testing/spec_helpers` began requiring `axn/testing` (and through it the full gem). The engine load is now deferred to `ActiveSupport.on_load(:before_configuration)` when Rails is absent at require time.
6
+
7
+ ### Performance
8
+
9
+ * [INTERNAL] The one-off ActiveModel validator class for a declared field/subfield is now compiled once per class and reused across every `.call`, instead of being recompiled (and its ActiveModel validator machinery re-instantiated) on every call. `auto_log` no longer builds its before/after payload when the configured logger's own severity level would discard it. `_model_fields` and `_declared_fields` are now cached per class instead of being rebuilt from the full contract on every reader definition / every read. Recovers roughly half of alpha-5's per-call allocation increase on the `basic` benchmark scenario — 489 → 250 objects/call (see PRO-3050).
10
+ * [BREAKING] `Result#declared_fields` now returns a frozen Array. Previously each call built a fresh Array, so appending to the one handed to a given result had no effect beyond that call; the Array is now cached and shared across every call of the class (see above), so mutating it raises `FrozenError` instead of silently being a no-op.
11
+
3
12
  ## 0.1.0-alpha.5
4
13
 
5
14
  Most of what follows announces itself: a new option, a rename, or a declaration error you now get at class definition instead of on every call. These are the changes that do **not** — re-run your suite and look at these areas.
@@ -43,11 +43,7 @@ module Axn
43
43
  end
44
44
  end
45
45
 
46
- def _model_fields
47
- action.internal_field_configs.each_with_object({}) do |config, hash|
48
- hash[config.field] = config.validations[:model] if config.validations.key?(:model)
49
- end
50
- end
46
+ def _model_fields = action.class._model_fields
51
47
 
52
48
  def action_name = @action.class.name.presence || "The action"
53
49
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module Contract
6
+ # Per-class cache of the one-off validator classes `Axn::Validation::Fields.validator_class_for`
7
+ # would otherwise compile fresh on every `.call` for every declared top-level field and subfield
8
+ # (PRO-3050). Mirrors `Redaction#_contract_redaction` exactly: outer key is `equal?` on the three
9
+ # copy-on-write config arrays (self-invalidating on any redeclaration, subclass, or
10
+ # Mountable/Factory rebuild — see redaction.rb's doctrine comment, which this reuses verbatim),
11
+ # inner key is the `FieldConfig`/`ShapeConfig` object's own IDENTITY.
12
+ #
13
+ # Identity, never `validations`: a config's `#validations` is not a stable object across reads.
14
+ # `Executor#_with_effective_coerce` mints a fresh merged Hash every call when `coerce_input_types`
15
+ # resolves on, and a config reached only via `internal_field_configs=`/`subfield_configs=` (never
16
+ # declared) can answer a DIFFERENT Hash on every `#validations` read (stored_shape_traversal_spec's
17
+ # generative member) — keying on either would never hit, or worse, would grow the table forever.
18
+ # The config object itself has neither problem: it is a frozen `Data`, minted once and replaced
19
+ # wholesale on redeclaration, never mutated.
20
+ #
21
+ # The SAME config can legitimately need two different compiled classes in one process — the
22
+ # `coerce_input_types` gate is per-call/per-class/global and can flip between calls — so the inner
23
+ # table is keyed on [config, coerce] rather than on config alone.
24
+ module ValidatorClassCache
25
+ def _validator_class_cache
26
+ internals = internal_field_configs
27
+ externals = external_field_configs
28
+ subfields = subfield_configs
29
+ memo = @_axn_validator_class_cache
30
+ return memo if memo&.current?(internals, externals, subfields)
31
+
32
+ @_axn_validator_class_cache = ValidatorClassCacheTable.new(internals:, externals:, subfields:)
33
+ end
34
+
35
+ # The compiled validator class for one config under one coerce state, built once per class per
36
+ # (config, coerce) pair and reused across every `.call`. `effective_validations` is the caller's
37
+ # already-resolved Hash (with `_with_effective_coerce` applied if relevant) — computed by the
38
+ # caller regardless of hit/miss, same as before this cache existed, so a hit only saves the
39
+ # `Class.new` + `validates` compilation, not the coerce merge.
40
+ def _cached_validator_class_for(config:, effective_validations:, coerce:)
41
+ _validator_class_cache.fetch(config:, coerce:) do
42
+ Axn::Validation::Fields.validator_class_for(field: config.field, validations: effective_validations)
43
+ end
44
+ end
45
+ end
46
+
47
+ # The table `_validator_class_cache` hands out. Mutable (so not a `Data`), one Hash slot written
48
+ # once per (config, coerce) pair. `||=` is safe here (unlike `_contract_redaction`'s `dynamic`
49
+ # flag): a compiled Class is always truthy, so there is no false/nil tri-state to guard.
50
+ class ValidatorClassCacheTable
51
+ def initialize(internals:, externals:, subfields:)
52
+ @internals = internals
53
+ @externals = externals
54
+ @subfields = subfields
55
+ # Per config, by identity — a FieldConfig/ShapeConfig defines no `hash`/`eql?` axn would want
56
+ # to run (its `validations` Hash may hold a caller-supplied option container that does), and
57
+ # identity is the right question anyway: the stored config is the one axn built.
58
+ @classes = {}.compare_by_identity
59
+ end
60
+
61
+ def current?(internals, externals, subfields)
62
+ @internals.equal?(internals) && @externals.equal?(externals) && @subfields.equal?(subfields)
63
+ end
64
+
65
+ def fetch(config:, coerce:)
66
+ by_coerce = (@classes[config] ||= {})
67
+ by_coerce[coerce] ||= yield
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -7,6 +7,7 @@ require "active_support/core_ext/module/delegation"
7
7
  require "active_support/core_ext/object/blank"
8
8
 
9
9
  require "axn/core/contract/redaction"
10
+ require "axn/core/contract/validator_class_cache"
10
11
  require "axn/core/contract/shape_declaration"
11
12
  require "axn/core/validation/fields"
12
13
  require "axn/core/flow/handlers/invoker"
@@ -365,6 +366,7 @@ module Axn
365
366
  # and in the same lookup position as when it lived in this file.
366
367
  include ShapeDeclaration
367
368
  include Redaction
369
+ include ValidatorClassCache
368
370
 
369
371
  # rubocop:disable Metrics/ParameterLists
370
372
  def expects(
@@ -534,23 +536,70 @@ module Axn
534
536
  end
535
537
  end
536
538
 
539
+ DeclaredFieldsCacheEntry = Data.define(:internal_field_configs, :external_field_configs, :fields)
540
+
541
+ # `configs.map(&:field)` was a fresh Array on every call — `Redaction#_context_slice` alone
542
+ # calls this twice per logged line (inbound + outbound), and the context facade calls it twice
543
+ # per action call. Cached per direction, invalidated by the identity of BOTH config arrays
544
+ # (rather than only the one a given direction reads) for simplicity: an outbound-only
545
+ # redeclaration then occasionally invalidates the (unaffected) :inbound slot too, which is
546
+ # never a wrong answer, only an avoidable rebuild.
547
+ #
548
+ # The cached Array is FROZEN before it's stored: `ContextFacade` exposes this exact object
549
+ # publicly as `Result#declared_fields`, so a fresh Array per call used to confine any caller
550
+ # mutation to that one facade — reusing the same object across every future call turns that
551
+ # mutation into permanent cache corruption (a caller-appended field name would start defining
552
+ # readers/passing `expose` for undeclared output on every subsequent call of the class) unless
553
+ # the shared object refuses to be mutated at all.
537
554
  def _declared_fields(direction)
538
555
  raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
539
556
 
557
+ internals = internal_field_configs
558
+ externals = external_field_configs
559
+ cache = (@_axn_declared_fields_cache ||= {})
560
+ cached = cache[direction]
561
+ return cached.fields if cached && cached.internal_field_configs.equal?(internals) && cached.external_field_configs.equal?(externals)
562
+
540
563
  configs = case direction
541
- when :inbound then internal_field_configs
542
- when :outbound then external_field_configs
543
- else (internal_field_configs + external_field_configs)
564
+ when :inbound then internals
565
+ when :outbound then externals
566
+ else (internals + externals)
544
567
  end
545
568
 
546
- configs.map(&:field)
569
+ cache[direction] = DeclaredFieldsCacheEntry.new(internal_field_configs: internals, external_field_configs: externals,
570
+ fields: configs.map(&:field).freeze)
571
+ cache[direction].fields
572
+ end
573
+
574
+ ModelFieldsCacheEntry = Data.define(:internal_field_configs, :value)
575
+
576
+ # Field => model options for every internal field carrying `model:`, cached per class: a pure
577
+ # function of `internal_field_configs`, rebuilt only when that array's identity changes (any
578
+ # redeclaration, subclass, or Mountable/Factory rebuild — see Redaction's doctrine comment).
579
+ # Moved here from the context facade instance (was rebuilt from scratch on every reader
580
+ # definition — O(fields defined × contract size) per action instance, since both the outbound
581
+ # Result facade and the inbound InternalContext facade instantiate one per call).
582
+ #
583
+ # Frozen before caching, same reasoning as `_declared_fields`: this is a public class method
584
+ # (so any caller can hold the live Hash), and the old per-call rebuild used to confine a
585
+ # caller mutation to that one read — reusing the same Hash across every future call would
586
+ # otherwise turn a mutation into permanent cross-call cache corruption.
587
+ def _model_fields
588
+ fields = internal_field_configs
589
+ cached = @_axn_model_fields
590
+ return cached.value if cached && cached.internal_field_configs.equal?(fields)
591
+
592
+ value = fields.each_with_object({}) { |config, hash| hash[config.field] = config.validations[:model] if config.validations.key?(:model) }
593
+ @_axn_model_fields = ModelFieldsCacheEntry.new(internal_field_configs: fields, value: value.freeze)
594
+ value
547
595
  end
548
596
 
549
597
  # Everything below is reached only with an implicit receiver, from here and from the other declaration
550
598
  # modules extended onto the same class. It is private because an `_`-prefixed name in a module extended
551
599
  # onto every action class otherwise lands there as a PUBLIC singleton method, so the convention and the
552
600
  # surface disagree. `_declared_fields` stays public above: the context facade, the redaction slice and
553
- # `Mountable`'s step passthrough call it on the action class from other files.
601
+ # `Mountable`'s step passthrough call it on the action class from other files. `_model_fields` stays
602
+ # public for the same reason: the context facade reads it from `facade.rb`.
554
603
  private
555
604
 
556
605
  # Reject `user_facing:` on any member of an `exposes` shape, at any depth. The block form
@@ -1053,8 +1053,9 @@ module Axn
1053
1053
  return _validate_inbound! if direction == :inbound
1054
1054
 
1055
1055
  failures = @action_class.send(:external_field_configs).filter_map do |config|
1056
- errors = Axn::Validation::Fields.collect_errors(field: config.field, validations: config.validations,
1057
- source: @action.result, action: @action)
1056
+ validator_class = @action_class._cached_validator_class_for(config:, effective_validations: config.validations, coerce: false)
1057
+ errors = Axn::Validation::Fields.errors_for(validator_class, source: @action.result, validations: config.validations, action: @action,
1058
+ permit_method_call: true)
1058
1059
  ContractFailure.new(config:, path: nil, errors:, stranded_at: nil) if errors.any?
1059
1060
  end
1060
1061
  raise OutboundValidationError, _aggregate_errors(failures, []) if failures.any?
@@ -1132,13 +1133,16 @@ module Axn
1132
1133
  coerce_input_types = _coerce_input_types?
1133
1134
 
1134
1135
  _inbound_configs.filter_map do |config|
1135
- errors = Axn::Validation::Fields.collect_errors(
1136
- field: config.field,
1137
- validations: coerce_input_types ? _with_effective_coerce(config.validations) : config.validations,
1136
+ effective_validations = coerce_input_types ? _with_effective_coerce(config.validations) : config.validations
1137
+ validator_class = @action_class._cached_validator_class_for(config:, effective_validations:, coerce: coerce_input_types)
1138
+ errors = Axn::Validation::Fields.errors_for(
1139
+ validator_class,
1138
1140
  source: config.subfield? ? _resolved_parent_value(config) : @action.internal_context,
1141
+ validations: effective_validations,
1139
1142
  action: @action,
1140
1143
  reader: config.subfield? ? config.reader_as : nil,
1141
1144
  config: config.subfield? ? config : nil,
1145
+ permit_method_call: true,
1142
1146
  )
1143
1147
  next if errors.empty?
1144
1148
 
@@ -8,7 +8,10 @@ module Axn
8
8
  # THE one-off validator collector, for every declared config at every level: a top-level field
9
9
  # validates against the context facade (which resolves model records and reads by wire key), a
10
10
  # subfield against its canonically-resolved parent value. One (field, validations) pair per
11
- # one-off class; raising/settling is the caller's concern (see Executor#_validate_inbound!).
11
+ # one-off class. The top-level/subfield path (Executor) and ShapeValidator both cache the compiled
12
+ # class and reuse it across calls/sources — see Contract::ValidatorClassCache and
13
+ # ShapeValidator#member_validator_classes respectively; raising/settling is the caller's concern
14
+ # (see Executor#_validate_inbound!).
12
15
  class Fields < Base
13
16
  def initialize(source)
14
17
  super()
@@ -31,7 +34,8 @@ module Axn
31
34
  # Two occupants reach here. A top-level/outbound FACADE read: its source is the framework's
32
35
  # own context/result facade, whose per-field reader is a safe generated accessor — method
33
36
  # dispatch is always permitted (it's not the caller-object dispatch the method_call gate
34
- # targets), so the facade call site (collect_errors) passes `permit_method_call: true`. A
37
+ # targets), so the facade call site (Executor, via .errors_for) passes `permit_method_call:
38
+ # true`. A
35
39
  # SHAPE MEMBER read (ShapeValidator): its source is a caller-supplied element, so it honors
36
40
  # the member's own `method_call:` opt-in — the same gate as a subfield (PRO-2907). The
37
41
  # permission is carried explicitly by each call site (NOT inferred from @action presence):
@@ -44,18 +48,11 @@ module Axn
44
48
  end
45
49
  end
46
50
 
47
- # Returns the ActiveModel::Errors for one (field, validations) pair against a source (empty if
48
- # valid). This is THE facade call site (top-level inbound + outbound): its source is the
49
- # framework's context/result facade, whose generated reader is safe, so it permits method
50
- # dispatch unconditionally. (A subfield reaches read_attribute_for_validation via the reader/
51
- # resolve_value branches, never the dispatch-gated else, so the flag is a no-op for it here.)
52
- def self.collect_errors(field:, validations:, source:, action: nil, reader: nil, config: nil)
53
- errors_for(validator_class_for(field:, validations:), source:, validations:, action:, reader:, config:, permit_method_call: true)
54
- end
55
-
56
- # Builds the one-off validator class for a (field, validations) pair. Callers that validate
57
- # the same contract repeatedly (e.g. ShapeValidator over array elements) can build this once
58
- # and reuse it across sources via .errors_for, avoiding per-call class compilation.
51
+ # Builds the one-off validator class for a (field, validations) pair. Every caller that validates
52
+ # the same contract repeatedly builds this ONCE and reuses it across sources via .errors_for:
53
+ # Contract::ValidatorClassCache for a top-level field/subfield (keyed on the FieldConfig's
54
+ # identity), ShapeValidator for a shape member (keyed on the member's field name, scoped to one
55
+ # compiled parent class).
59
56
  def self.validator_class_for(field:, validations:)
60
57
  Class.new(self) do
61
58
  def self.name = "Axn::Validation::Fields::OneOff"
@@ -71,7 +68,7 @@ module Axn
71
68
 
72
69
  # Runs a validator class against a source and returns its ActiveModel::Errors (empty if valid).
73
70
  # `permit_method_call:` governs the dispatch gate in the else branch of
74
- # read_attribute_for_validation: the facade call site (collect_errors) passes `true`; a shape
71
+ # read_attribute_for_validation: the facade call site (Executor) passes `true`; a shape
75
72
  # member passes its own `method_call:` opt-in (PRO-2907). It is deliberately independent of
76
73
  # `action:` so the two can be threaded separately.
77
74
  #
@@ -48,6 +48,13 @@ module Axn
48
48
  return unless level
49
49
 
50
50
  Axn::Extensions.best_effort(error_context, action: action_class) do
51
+ # Guarded by the same best_effort boundary as everything else in this block: a custom
52
+ # logger's severity predicate can itself raise, and that must be absorbed exactly like any
53
+ # other formatting failure here — some callers (call_async's invocation log, the
54
+ # enqueue-all completion log) invoke log_at_level with no other best_effort wrapping it, so
55
+ # checking the predicate outside this boundary could abort the call it only meant to log.
56
+ next unless would_log?(level)
57
+
51
58
  # Prepare and format context if needed
52
59
  context_str = if context_instance && context_direction
53
60
  # Instance-level: use private inputs_for_logging / outputs_for_logging
@@ -91,6 +98,21 @@ module Axn
91
98
  defined?(SemanticLogger::Logger) && Axn.config.logger.is_a?(SemanticLogger::Logger)
92
99
  end
93
100
 
101
+ # Whether the configured logger would actually emit at `level`, read via the logger's OWN
102
+ # severity predicate (`debug?`/`info?`/`warn?`/`error?`/`fatal?` — the same query Ruby's stdlib
103
+ # `Logger` and `SemanticLogger::Logger` already expose). A logger that doesn't respond to the
104
+ # predicate is assumed to emit, which matches today's behavior exactly — a custom logger
105
+ # implementing only the plain level methods loses nothing. This is deliberately NOT a switch to
106
+ # block-form logging (`logger.info { msg }`): that would silently drop the message for a custom
107
+ # logger whose level methods take a positional argument only and ignore an unused block. Public:
108
+ # `log_at_level` is the only caller, from this same module, but kept alongside `semantic_logger?`
109
+ # for the same reason that one is public.
110
+ def would_log?(level)
111
+ logger = Axn.config.logger
112
+ predicate = :"#{level}?"
113
+ !logger.respond_to?(predicate) || logger.public_send(predicate)
114
+ end
115
+
94
116
  private
95
117
 
96
118
  # Labeled readable suffix for the plain line, each group rendered with the same
data/lib/axn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Axn
4
- VERSION = "0.1.0-alpha.5"
4
+ VERSION = "0.1.0-alpha.5.1"
5
5
  end
data/lib/axn.rb CHANGED
@@ -51,8 +51,15 @@ require "axn/util/execution_context"
51
51
  require "axn/mountable"
52
52
  require "axn/async"
53
53
 
54
- # Rails integration (if in Rails context)
55
- require "axn/rails/engine" if defined?(Rails) && Rails.const_defined?(:Engine)
54
+ # Rails integration. The `defined?` check runs once and re-requiring axn is a no-op, so a host that
55
+ # loads axn before Rails -- the conventional RSpec layout does -- would otherwise never get the
56
+ # engine. `:before_configuration` fires from `Rails::Application#initialize`, before railties are
57
+ # collected, and runs immediately if Rails is already past that point.
58
+ if defined?(Rails) && Rails.const_defined?(:Engine)
59
+ require "axn/rails/engine"
60
+ else
61
+ ActiveSupport.on_load(:before_configuration) { require "axn/rails/engine" }
62
+ end
56
63
 
57
64
  module Axn
58
65
  def self.included(base)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: axn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.5
4
+ version: 0.1.0.pre.alpha.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
@@ -82,6 +82,7 @@ files:
82
82
  - lib/axn/core/contract/redaction.rb
83
83
  - lib/axn/core/contract/shape_declaration.rb
84
84
  - lib/axn/core/contract/subfield_contradictions.rb
85
+ - lib/axn/core/contract/validator_class_cache.rb
85
86
  - lib/axn/core/contract_for_subfields.rb
86
87
  - lib/axn/core/default_call.rb
87
88
  - lib/axn/core/executor.rb