typed_eav 0.2.1 → 0.3.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -0
  3. data/README.md +287 -47
  4. data/app/models/typed_eav/field/base.rb +28 -159
  5. data/app/models/typed_eav/field/currency.rb +54 -20
  6. data/app/models/typed_eav/field/date.rb +16 -1
  7. data/app/models/typed_eav/field/date_time.rb +16 -1
  8. data/app/models/typed_eav/field/decimal.rb +9 -1
  9. data/app/models/typed_eav/field/email.rb +13 -16
  10. data/app/models/typed_eav/field/integer.rb +6 -1
  11. data/app/models/typed_eav/field/long_text.rb +17 -1
  12. data/app/models/typed_eav/field/multi_select.rb +12 -9
  13. data/app/models/typed_eav/field/optionable.rb +59 -0
  14. data/app/models/typed_eav/field/percentage.rb +6 -6
  15. data/app/models/typed_eav/field/range_bounded.rb +71 -0
  16. data/app/models/typed_eav/field/select.rb +9 -10
  17. data/app/models/typed_eav/field/text.rb +11 -29
  18. data/app/models/typed_eav/field/url.rb +14 -16
  19. data/app/models/typed_eav/field/validated_string.rb +87 -0
  20. data/app/models/typed_eav/value.rb +9 -9
  21. data/lib/typed_eav/bulk_read.rb +124 -0
  22. data/lib/typed_eav/engine.rb +1 -1
  23. data/lib/typed_eav/entity_query.rb +186 -0
  24. data/lib/typed_eav/field/typed_storage.rb +205 -0
  25. data/lib/typed_eav/filter_query.rb +148 -0
  26. data/lib/typed_eav/has_typed_eav/instance_methods.rb +253 -0
  27. data/lib/typed_eav/has_typed_eav.rb +29 -793
  28. data/lib/typed_eav/partition.rb +51 -11
  29. data/lib/typed_eav/query_builder.rb +6 -7
  30. data/lib/typed_eav/scope_tuple.rb +116 -0
  31. data/lib/typed_eav/version.rb +1 -1
  32. data/lib/typed_eav/versioning/subscriber.rb +7 -6
  33. data/lib/typed_eav.rb +23 -64
  34. metadata +10 -4
  35. data/lib/typed_eav/column_mapping.rb +0 -110
  36. data/lib/typed_eav/currency_storage_contract.rb +0 -46
  37. data/lib/typed_eav/field_storage_contract.rb +0 -68
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 630fefbfa32546ed8863b3971600d58188322895db16e80525eac5b8500f7cea
4
- data.tar.gz: 77a27d9c81984d5c109fac16d87dd79ea34eb15bd10d00a0d62d3c4d9716c65c
3
+ metadata.gz: 66997bbaca41b0302fe7a86baf9b22233f2ade442c2255c3323a900245bd78fb
4
+ data.tar.gz: 90498aa57c720504bc09a34e62fab81d0377baaeab3ea2d0598317d6d5b38a93
5
5
  SHA512:
6
- metadata.gz: 53ab3bd2e9ea76c602e9bfc556d97a20a4caa0b986fce7c3704c149f51b0ba8fedbe25994a40fa38fb227f5dfdec7c54db4c6e9c77af6f6a67fc33edc0edf3f0
7
- data.tar.gz: 1e5abe7926aa77074b753edc7ad3b6f9b243595acc62d13bfdaf432ef5b71ee17afbb05566732c99543c6eaf30ee2d0bfbd368f1aec6ae02b6eab3415e60f4fe
6
+ metadata.gz: a67ca56147f16ca0b6ffa02121f5783dc10f588ae36bc4d6b9df73c5de19bfac22be3b76613c693719156d15b8442723de4fd05479f72dc8074ad5b272c2599e
7
+ data.tar.gz: f4802412a11f7d5010487c2bede22037a6302653269eaf2dd4a933f98612296d38b2a6ce4bdd6244db1286c6572ac706615e9723ed73a2bc0ac582289af7ebd8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,138 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.1] - 2026-05-25
9
+
10
+ Documentation-only release. No code or behavior changes.
11
+
12
+ ### Added
13
+
14
+ - README §"Architecture" — full overview of the post-0.3.0 internal
15
+ module layout: macro entry (`HasTypedEav`), the two-altitude query
16
+ pattern (`EntityQuery` → `FilterQuery` → `QueryBuilder`), `BulkRead`
17
+ and `BulkWrite` siblings, `InstanceMethods`, `Field::TypedStorage`
18
+ concern, family intermediate bases (`ValidatedString`, `RangeBounded`,
19
+ `Optionable`), `ScopeTuple`, `Partition`, `EventDispatcher`, and the
20
+ Phase-6 modules (`SchemaPortability`, `CSVMapper`). Anchored to ADRs
21
+ 0001–0005 throughout.
22
+
23
+ ### Removed
24
+
25
+ - `TEST_PLAN.md` — pre-0.3.0 test-sweep planning artifact (2026-04-08).
26
+ Described specs for modules deleted in #9. Git history preserves it.
27
+ - `typed_eav-enhancement-plan.md` — pre-0.3.0 phased roadmap. References
28
+ v0.1.0 line numbers and Phase-1 work that has since shipped. Git
29
+ history preserves it.
30
+
31
+ ## [0.3.0] - 2026-05-25
32
+
33
+ Pre-1.0 architecture cleanup arc (issues #9–#13). No public-API breakage
34
+ for host AR models or registered custom field types; behavior changes are
35
+ limited to two latent-bug fixes (now raised at field-save) and one
36
+ internal helper relocation (see "Changed" below). Anchored by ADRs
37
+ 0001–0005.
38
+
39
+ ### Added
40
+
41
+ - New `TypedEAV::Field::TypedStorage` concern (auto-included on
42
+ `Field::Base`) collapses the prior storage stack into three paired
43
+ override points: `read_value(record)`, `write_value(record, casted)`,
44
+ `apply_default(record)`. Custom multi-cell field types now extend
45
+ `Field::Base` directly and override only these methods. See README
46
+ §"Multi-cell field types" and ADR-0001 (issue #9).
47
+ - New top-level `TypedEAV::ScopeTuple` module exposes the
48
+ `[scope, parent_scope]` normalization surface: `normalize_permissive`,
49
+ `normalize_strict`, and `invariant_satisfied?`. Used by `Partition`,
50
+ `TypedEAV.with_scope`, `Config#resolve_scope`, and the query path
51
+ (issue #10).
52
+ - New top-level query objects extracted from `HasTypedEAV`:
53
+ `TypedEAV::EntityQuery` (class-method orchestration on host AR models),
54
+ `TypedEAV::FilterQuery` (multi-filter SQL composition for
55
+ `where_typed_eav` / `with_field`), and `TypedEAV::BulkRead` (bulk
56
+ per-record reads via `eav_values_for`). See ADR-0002 (issue #11).
57
+ - New field family intermediate bases collapse per-leaf duplication:
58
+ - `TypedEAV::Field::ValidatedString` — min/max-length + regex-pattern
59
+ validation surface for `string_value`-backed types (parent of
60
+ `Email` and `Url`).
61
+ - `TypedEAV::Field::RangeBounded` — min/max-bound validation helpers
62
+ for comparable single-value types (parent of `Integer`, `Decimal`,
63
+ `Date`, `DateTime`).
64
+ - `TypedEAV::Field::Optionable` — concern (not parent) for types that
65
+ draw values from a `Field::Option` set; included by `Select` and
66
+ `MultiSelect`.
67
+
68
+ See README §"Family intermediate bases (extension points)" and
69
+ ADR-0004 (issue #12).
70
+
71
+ ### Changed
72
+
73
+ - **Internal helper move.** `TypedEAV::HasTypedEAV.definitions_by_name`
74
+ and `TypedEAV::HasTypedEAV.definitions_multimap_by_name` moved to
75
+ `TypedEAV::Partition.definitions_by_name` /
76
+ `TypedEAV::Partition.definitions_multimap_by_name`. These helpers were
77
+ technically callable from application code but not documented;
78
+ partition-tuple precedence is a partition concept and the new home
79
+ reflects that. External callers (if any) should update the call site.
80
+ See ADR-0002 (issue #11).
81
+ - `TypedEAV::HasTypedEAV` is now a slim macro module
82
+ (`lib/typed_eav/has_typed_eav.rb`) that delegates to a per-instance
83
+ methods file (`lib/typed_eav/has_typed_eav/instance_methods.rb`) plus
84
+ the new `EntityQuery` / `FilterQuery` / `BulkRead` objects. Public
85
+ class-method and instance-method signatures on host AR models are
86
+ unchanged. See ADR-0002 (issue #11).
87
+ - Field validation now runs paired-bound checks at field-save time, not
88
+ only at value-write time:
89
+ - `Field::Email` / `Field::Url` (via `ValidatedString`) reject
90
+ `max_length < min_length` when the field record is saved.
91
+ - `Field::Date` / `Field::DateTime` (via `RangeBounded` leaves) reject
92
+ inverted `min_date`/`max_date` (and `min_datetime`/`max_datetime`)
93
+ bounds when the field record is saved.
94
+
95
+ Both were latent bugs prior to v0.3.0 — the bound mismatch was only
96
+ surfaced when a `Value` was written. Authors of custom field types
97
+ that store inverted bounds will now see the validation fail earlier.
98
+ See ADR-0004 (issue #12).
99
+
100
+ ### Removed
101
+
102
+ - `TypedEAV::Field::FieldStorageContract`,
103
+ `TypedEAV::Field::CurrencyStorageContract`, and
104
+ `TypedEAV::Field::ColumnMapping` are deleted; their surface lives on
105
+ `Field::TypedStorage`. ADR-0001 (issue #9).
106
+ - `TypedEAV::Partition.validate_tuple!` is deleted; callers use
107
+ `TypedEAV::ScopeTuple.normalize_strict` directly. Issue #10.
108
+
109
+ ### Internal
110
+
111
+ - `TypedEAV::EventDispatcher` is retained as the synchronous broker
112
+ between `TypedEAV::Hooks` and `ActiveSupport::Notifications`. The
113
+ cleanup arc explicitly considered collapsing it and rejected that:
114
+ the broker is the seam where event-name normalization and the
115
+ `notifications: false` opt-out live. See ADR-0003.
116
+ - The Phase-6 modules — `TypedEAV::BulkWrite`,
117
+ `TypedEAV::Importers::CSVMapper`, and
118
+ `TypedEAV::SchemaPortability::*` — remain independent top-level
119
+ modules. The cleanup arc explicitly considered consolidating them
120
+ under a single `TypedEAV::Operations` namespace and rejected that:
121
+ the modules share no internal contract and the namespace would be
122
+ cosmetic. See ADR-0005.
123
+ - Cyclomatic-complexity rubocop disables that previously masked the
124
+ `HasTypedEAV` mega-module are gone — the split files clear the
125
+ default complexity thresholds.
126
+
127
+ ### References
128
+
129
+ - Issue #9 — `Field::TypedStorage` concern.
130
+ - Issue #10 — `ScopeTuple` extraction.
131
+ - Issue #11 — `EntityQuery` / `FilterQuery` / `BulkRead` split.
132
+ - Issue #12 — Field family intermediate bases.
133
+ - Issue #13 — release coordination.
134
+ - ADR-0001 — collapse field storage stack.
135
+ - ADR-0002 — split `HasTypedEAV` into query objects.
136
+ - ADR-0003 — retain `EventDispatcher` as broker.
137
+ - ADR-0004 — field family intermediate bases.
138
+ - ADR-0005 — keep Phase-6 modules independent.
139
+
8
140
  ## [0.2.1] - 2026-05-08
9
141
 
10
142
  Metadata-only release.
@@ -96,6 +228,8 @@ worked examples.
96
228
 
97
229
  Initial release.
98
230
 
231
+ [0.3.1]: https://github.com/dchuk/typed_eav/releases/tag/v0.3.1
232
+ [0.3.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.3.0
99
233
  [0.2.1]: https://github.com/dchuk/typed_eav/releases/tag/v0.2.1
100
234
  [0.2.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.2.0
101
235
  [0.1.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.1.0
data/README.md CHANGED
@@ -555,71 +555,189 @@ TypedEAV.configure do |c|
555
555
  end
556
556
  ```
557
557
 
558
+ ### Family intermediate bases (extension points)
559
+
560
+ `Field::Base` is the universal parent, but three intermediate family
561
+ bases collapse the most common per-leaf duplication. Pick the right
562
+ parent and you inherit the family's validation surface for free.
563
+
564
+ - **`TypedEAV::Field::ValidatedString`** — subclass when your custom
565
+ type stores in `string_value` and wants a min/max-length + regex-pattern
566
+ validation surface. Inherits `value_column :string_value`,
567
+ `store_accessor :options, :min_length, :max_length, :pattern`,
568
+ numericality validators on `min_length` / `max_length`, a
569
+ `max_gte_min_length` guard that rejects inverted bounds at field-save,
570
+ and a `validate_pattern_syntax` guard that rejects bad regexes at
571
+ field-save. The default `validate_typed_value(record, val)` runs
572
+ `validate_length` plus `validate_pattern if pattern.present?`. Override
573
+ it and call `super` to layer on a format-specific check (the built-in
574
+ `Field::Email` / `Field::Url` are the canonical pattern).
575
+
576
+ ```ruby
577
+ class Fields::Slug < TypedEAV::Field::ValidatedString
578
+ SLUG_FORMAT = /\A[a-z0-9-]+\z/
579
+
580
+ def cast(raw)
581
+ [raw&.to_s&.strip&.downcase, false]
582
+ end
583
+
584
+ def validate_typed_value(record, val)
585
+ super # length + pattern from the family base
586
+ record.errors.add(:value, "is not a valid slug") unless SLUG_FORMAT.match?(val.to_s)
587
+ end
588
+ end
589
+ ```
590
+
591
+ - **`TypedEAV::Field::RangeBounded`** — subclass when your custom type
592
+ stores a single comparable value (numeric or temporal) constrained by
593
+ a min/max bound. Each leaf still declares its own `value_column` and
594
+ its own `store_accessor` (key names vary by family member: `:min`/`:max`
595
+ for numeric; `:min_date`/`:max_date` for date;
596
+ `:min_datetime`/`:max_datetime` for datetime). The family base
597
+ provides protected `validate_range` / `validate_date_range` /
598
+ `validate_datetime_range` helpers. Each leaf should pair its
599
+ `store_accessor` with the macro
600
+ `validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min`
601
+ (or the analogous form for the leaf's key names) so inverted bounds
602
+ fail at field-save.
603
+
604
+ ```ruby
605
+ class Fields::Score < TypedEAV::Field::RangeBounded
606
+ value_column :integer_value
607
+
608
+ store_accessor :options, :min, :max
609
+ validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
610
+
611
+ def cast(raw)
612
+ raw.nil? ? [nil, false] : [Integer(raw.to_s, exception: false), raw.to_s.empty? ? false : true]
613
+ end
614
+
615
+ def validate_typed_value(record, val)
616
+ validate_range(record, val)
617
+ end
618
+ end
619
+ ```
620
+
621
+ - **`TypedEAV::Field::Optionable`** — `include` this concern when your
622
+ custom type's valid values are drawn from a `Field::Option` set.
623
+ Provides `optionable? = true`, a public-facing sorted
624
+ `allowed_values` helper, and protected
625
+ `validate_option_inclusion` / `validate_multi_option_inclusion`
626
+ helpers. Mixin (not inheritance) because option-set field types may
627
+ use different `value_column`s — the built-in `Field::Select` stores in
628
+ `string_value` while `Field::MultiSelect` stores in `json_value`, and
629
+ both stay as direct children of `Field::Base`.
630
+
631
+ ```ruby
632
+ class Fields::Tag < TypedEAV::Field::Base
633
+ include TypedEAV::Field::Optionable
634
+
635
+ value_column :string_value
636
+ operators :eq, :not_eq, :is_null, :is_not_null
637
+
638
+ def cast(raw)
639
+ [raw&.to_s, false]
640
+ end
641
+
642
+ def validate_typed_value(record, val)
643
+ validate_option_inclusion(record, val)
644
+ end
645
+ end
646
+ ```
647
+
648
+ The rule of thumb: subclass an intermediate family base when the new
649
+ field type shares its storage and validation surface with the family;
650
+ include `Optionable` when it draws values from an option set; subclass
651
+ `Field::Base` directly (as the `Phone` example above does) when none of
652
+ the family surfaces fit. `validate_array_size` lives on `Field::Base`
653
+ itself — its callers span unrelated families.
654
+
558
655
  ### Multi-cell field types
559
656
 
560
657
  External field types may store their logical value across multiple typed
561
- columns. Storage behavior is exposed through `field.storage_contract`,
562
- which keeps callers from knowing whether a field is single-cell or
563
- multi-cell. The contract covers:
564
-
565
- - `value_columns` - the native typed cells used for storage, snapshots,
566
- reverting, and update change detection.
567
- - `read(value_record)` / `write(value_record, casted)` - logical value
568
- reads and writes.
569
- - `apply_default(value_record)` - default application across the field's
570
- storage cells.
571
- - `query_column(operator)` - query routing for each supported operator.
572
- - `before_snapshot(value_record, change_type)` /
573
- `after_snapshot(value_record, change_type)` - version row jsonb shape.
574
- - `changed?(value_record)` - update event gating across every storage
575
- cell the field owns.
576
-
577
- Single-cell field types inherit the default contract. For multi-cell
578
- fields, create a `TypedEAV::FieldStorageContract` subclass and select it
579
- from the field class:
658
+ columns. The entire storage surface lives directly on `Field::Base` via
659
+ the `Field::TypedStorage` concern, so a custom multi-cell type is just a
660
+ `Field::Base` subclass that overrides three instance methods.
661
+
662
+ **Class-level DSL** (declared at class load time):
663
+
664
+ - `value_column :col` single-cell sugar; declares the primary cell.
665
+ - `value_columns :a, :b, ...` – plural form for multi-cell types. The
666
+ primary cell is `value_columns.first`. Both forms share storage;
667
+ `value_column` and `value_columns` are interchangeable getters/setters.
668
+ - `operators :eq, :gt, ...` restrict the supported operator set.
669
+ - `self.operator_column(op)` – override to route different operators to
670
+ different cells. Defaults to `value_columns.first`.
671
+
672
+ **Override-point instance methods** (the entire extension surface for
673
+ multi-cell types):
674
+
675
+ - `read_value(record)` compose the logical value from the cells.
676
+ - `write_value(record, casted)` – unpack the casted value across cells.
677
+ - `apply_default(record)` – populate cells from `default_value`.
678
+
679
+ The defaults target `value_columns.first`, so single-cell field types
680
+ keep working without overrides. The three methods are paired – override
681
+ all three or your reads will see a multi-cell shape that writes / defaults
682
+ cannot produce.
683
+
684
+ **Concrete snapshot helpers** (NOT overridable; derived from
685
+ `value_columns`):
686
+
687
+ - `value_changed?(record)` – true iff any cell saw a saved change.
688
+ - `before_snapshot(record, change_type)` / `after_snapshot(record, change_type)`
689
+ – per-cell hashes keyed by string column names; powers the versioning
690
+ jsonb shape.
691
+
692
+ Custom multi-cell type example (matches the built-in `Field::Currency`):
580
693
 
581
694
  ```ruby
582
- class MoneyStorageContract < TypedEAV::FieldStorageContract
583
- def self.value_columns = %i[decimal_value string_value]
584
- def self.query_column(operator) = operator == :currency_eq ? :string_value : :decimal_value
585
-
586
- def read(value_record)
587
- amount = value_record[:decimal_value]
588
- currency = value_record[:string_value]
589
- amount.nil? && currency.nil? ? nil : { amount: amount, currency: currency }
695
+ class Fields::Money < TypedEAV::Field::Base
696
+ AMOUNT_COLUMN = :decimal_value
697
+ CURRENCY_COLUMN = :string_value
698
+
699
+ value_columns AMOUNT_COLUMN, CURRENCY_COLUMN
700
+ operators :eq, :gt, :lt, :gteq, :lteq, :between, :currency_eq, :is_null, :is_not_null
701
+
702
+ def self.operator_column(operator)
703
+ operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN
704
+ end
705
+
706
+ def read_value(value_record)
707
+ amount = value_record[AMOUNT_COLUMN]
708
+ currency = value_record[CURRENCY_COLUMN]
709
+ return nil if amount.nil? && currency.nil?
710
+
711
+ { amount: amount, currency: currency }
590
712
  end
591
713
 
592
- def write(value_record, casted)
593
- value_record[:decimal_value] = casted&.fetch(:amount, nil)
594
- value_record[:string_value] = casted&.fetch(:currency, nil)
714
+ def write_value(value_record, casted)
715
+ if casted.nil?
716
+ value_record[AMOUNT_COLUMN] = nil
717
+ value_record[CURRENCY_COLUMN] = nil
718
+ else
719
+ value_record[AMOUNT_COLUMN] = casted[:amount]
720
+ value_record[CURRENCY_COLUMN] = casted[:currency]
721
+ end
595
722
  end
596
723
 
597
724
  def apply_default(value_record)
598
- default = field.default_value
725
+ default = default_value
599
726
  return unless default.is_a?(Hash)
600
727
 
601
- value_record[:decimal_value] = default[:amount] || default["amount"]
602
- value_record[:string_value] = default[:currency] || default["currency"]
728
+ value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"]
729
+ value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"]
603
730
  end
604
731
  end
605
-
606
- class Fields::Money < TypedEAV::Field::Base
607
- value_column :decimal_value
608
- storage_contract_class MoneyStorageContract
609
- end
610
732
  ```
611
733
 
612
- Compatibility helpers such as `self.value_columns` and
613
- `self.operator_column(operator)` may delegate to the selected contract
614
- when older callers still use those class methods.
615
-
616
- Defaults delegate to `value_column` for single-cell storage, so existing
617
- single-cell types are unchanged. The built-in `Field::Currency` is the
618
- canonical multi-cell consumer of these extension points.
734
+ The built-in `Field::Currency` is the canonical multi-cell consumer of
735
+ these extension points and reads as a normal `Field::Base` subclass with
736
+ exactly three method overrides.
619
737
 
620
738
  ### Built-in field types
621
739
 
622
- - **`Currency`:** Stores `{amount: BigDecimal, currency: String}` across two typed columns (`decimal_value` for the amount; `string_value` for the ISO 4217 currency code) through `CurrencyStorageContract`. Operators: `:eq`, `:gt`, `:lt`, `:gteq`, `:lteq`, `:between` target the amount; `:currency_eq` targets the currency code; `:is_null` / `:is_not_null` target the amount column (a Currency value is null when its amount is null). Cast input MUST be a hash with `:amount` and/or `:currency` keys — bare numeric/string values are rejected with `:invalid` to enforce explicit currency dimension at write time. Options: `default_currency` (String ISO code, applied as fallback only when an amount is given without an explicit currency), `allowed_currencies` (Array of ISO codes; `validate_typed_value` enforces inclusion). Versioning snapshots automatically capture both columns through the storage contract. The `:currency_eq` operator is registered ONLY on `Field::Currency`; the QueryBuilder operator-validation gate rejects it with a clear `ArgumentError` if invoked on any other field type.
740
+ - **`Currency`:** Stores `{amount: BigDecimal, currency: String}` across two typed columns (`decimal_value` for the amount; `string_value` for the ISO 4217 currency code). Multi-cell storage is declared via `value_columns :decimal_value, :string_value`; reads, writes, and default application override `read_value`, `write_value`, and `apply_default` directly on `Field::Currency`. Operators: `:eq`, `:gt`, `:lt`, `:gteq`, `:lteq`, `:between` target the amount; `:currency_eq` targets the currency code; `:is_null` / `:is_not_null` target the amount column (a Currency value is null when its amount is null). Cast input MUST be a hash with `:amount` and/or `:currency` keys — bare numeric/string values are rejected with `:invalid` to enforce explicit currency dimension at write time. Options: `default_currency` (String ISO code, applied as fallback only when an amount is given without an explicit currency), `allowed_currencies` (Array of ISO codes; `validate_typed_value` enforces inclusion). Versioning snapshots automatically capture both columns because the snapshot helpers iterate `value_columns`. The `:currency_eq` operator is registered ONLY on `Field::Currency`; the QueryBuilder operator-validation gate rejects it with a clear `ArgumentError` if invoked on any other field type.
623
741
 
624
742
  ```ruby
625
743
  Contact.where_typed_eav(name: "price", op: :currency_eq, value: "USD")
@@ -676,7 +794,7 @@ canonical multi-cell consumer of these extension points.
676
794
  Contact.where_typed_eav(name: "manager", op: :references, value: 42) # filter by FK
677
795
  ```
678
796
 
679
- - **Summary:** The built-in field types **Image, File, Reference, Currency, Percentage** all preserve the cast-tuple contract (`[casted, invalid?]`), the operator-dispatch model (`supported_operators` + `operator_column` for multi-cell types), and the no-hardcoded-attribute-references foundational principle. The multi-cell extension surface (`read_value`, `apply_default_to`, `operator_column`, and `write_value`) is the canonical way to build any future external multi-cell field type.
797
+ - **Summary:** The built-in field types **Image, File, Reference, Currency, Percentage** all preserve the cast-tuple contract (`[casted, invalid?]`), the operator-dispatch model (`supported_operators` + `operator_column` for multi-cell types), and the no-hardcoded-attribute-references foundational principle. The multi-cell extension surface (`read_value`, `write_value`, `apply_default`, and `operator_column`) is the canonical way to build any future external multi-cell field type.
680
798
 
681
799
  ## Validation Behavior
682
800
 
@@ -1121,6 +1239,128 @@ The gem creates four tables:
1121
1239
  - `typed_eav_options` - allowed values for select/multi-select fields
1122
1240
  - `typed_eav_sections` - optional UI grouping
1123
1241
 
1242
+ ## Architecture
1243
+
1244
+ Internal module layout as of 0.3.0. Most consumers never reach for these directly — the public surface is the `has_typed_eav` macro and the instance/class methods it installs — but the split matters if you're extending the gem, debugging an integration, or evaluating it for production. Decisions are anchored by [ADR-0001](docs/adr/0001-collapse-column-mapping-stack.md) through [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md).
1245
+
1246
+ ### Macro entry: `HasTypedEav`
1247
+
1248
+ `lib/typed_eav/has_typed_eav.rb` (~120 LOC) is the macro shell. When you call `has_typed_eav` on an AR model, it:
1249
+
1250
+ 1. `extend`s `TypedEAV::EntityQuery` onto the class (class-level query methods).
1251
+ 2. `include`s `TypedEAV::HasTypedEav::InstanceMethods` (per-record accessors).
1252
+ 3. Wires scope/parent-scope kwargs into the model's class-level configuration.
1253
+ 4. Registers the model with `TypedEAV::Registry`.
1254
+
1255
+ The macro is intentionally thin. All real behavior lives in the modules it pulls in.
1256
+
1257
+ ### Class-level reads: two-altitude query pattern
1258
+
1259
+ ```
1260
+ Contact.where_typed_eav(...) ← public class method
1261
+
1262
+
1263
+ TypedEAV::EntityQuery ← high altitude: orchestrator
1264
+ • resolves scope/parent_scope from ambient context or explicit kwargs
1265
+ • owns the UNSET_SCOPE / ALL_SCOPES sentinels
1266
+ • delegates to FilterQuery
1267
+
1268
+
1269
+ TypedEAV::FilterQuery ← multi-filter composition
1270
+ • normalizes filter input shapes (positional, hash, hash-of-hashes)
1271
+ • looks up field definitions via TypedEAV::Partition
1272
+ • per filter, asks QueryBuilder for the SQL fragment
1273
+ • unions/intersects per-field entity-id sets
1274
+ • returns an ActiveRecord::Relation scoped to the host model
1275
+
1276
+
1277
+ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
1278
+ • turns a single (field, op, value) into a WHERE clause against typed_eav_values
1279
+ • knows about typed-column projections (integer_value, string_value, etc.)
1280
+ • knows about operator-specific column choice (currency-cents vs currency-code)
1281
+ ```
1282
+
1283
+ `QueryBuilder` is the single place that decides "given this field and this operator, which column and which SQL fragment?" `FilterQuery` never builds SQL fragments directly; `EntityQuery` never touches columns. Splitting the two altitudes keeps custom field types extending only the column-mapping surface (`value_column`, `operators`, `operator_column`) without ever subclassing `FilterQuery`.
1284
+
1285
+ ### Bulk reads: `BulkRead`
1286
+
1287
+ `typed_eav_hash_for(records)` (the plural read) routes through `TypedEAV::BulkRead`. Given a record collection and an effective `(scope, parent_scope)`, it:
1288
+
1289
+ 1. Groups records by partition tuple via `TypedEAV::Partition.definitions_by_name`.
1290
+ 2. Issues one batched `WHERE entity_id IN (...) AND field_id IN (...)` query per partition.
1291
+ 3. Returns a `{record_id => {field_name => value}}` map.
1292
+
1293
+ Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMethods` and use the same partition helpers but without batching.
1294
+
1295
+ ### Bulk writes: `BulkWrite`
1296
+
1297
+ `bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`, an executor that:
1298
+
1299
+ 1. Memoizes field definitions for the call via `Thread.current[:typed_eav_bulk_defs_memo]`.
1300
+ 2. Validates each attribute against its field type's cast contract.
1301
+ 3. Upserts in a single SQL round trip per typed column.
1302
+
1303
+ `BulkWrite` and `BulkRead` are siblings — one read path, one write path — but they don't share a base class. Per [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md), keeping them independent preserves the option to evolve each on its own schedule.
1304
+
1305
+ ### Per-record reads/writes: `InstanceMethods`
1306
+
1307
+ `lib/typed_eav/has_typed_eav/instance_methods.rb` (~250 LOC) holds the per-record API:
1308
+
1309
+ - `typed_eav_value(name)` / `typed_eav_hash` — reads
1310
+ - `set_typed_eav_value(name, value)` / `typed_eav_attributes=` — writes
1311
+ - `typed_eav_changes` — dirty tracking
1312
+ - `typed_eav_scope` / `typed_eav_parent_scope` — scope resolution per record
1313
+
1314
+ Every method uses `TypedEAV::Partition.definitions_by_name` so the collision-precedence rules for ambient/explicit/parent scopes are computed in one place.
1315
+
1316
+ ### Field types and storage: `Field::TypedStorage`
1317
+
1318
+ `TypedEAV::Field::Base` is the STI parent of every field type. The shared storage surface lives in the `TypedEAV::Field::TypedStorage` concern (`lib/typed_eav/field/typed_storage.rb`, ~200 LOC), auto-included on `Field::Base`. Per [ADR-0001](docs/adr/0001-collapse-column-mapping-stack.md), it provides:
1319
+
1320
+ - **Class DSL**: `value_column`, `value_columns`, `operators`, `operator_column`, `supported_operators` — describe where typed values live and which operators they support.
1321
+ - **Instance override points**: `read_value(record)`, `write_value(record, casted)`, `apply_default(record)` — the three methods a multi-cell field type overrides.
1322
+ - **Concrete snapshot helpers**: `value_changed?`, `before_snapshot`, `after_snapshot` — derived automatically from `value_columns`; not overridable.
1323
+
1324
+ Custom multi-cell field types subclass `Field::Base` directly and override only the three instance methods. See §[Multi-cell field types](#multi-cell-field-types) for `Currency` as the canonical worked example.
1325
+
1326
+ ### Field families: intermediate STI bases
1327
+
1328
+ Per [ADR-0004](docs/adr/0004-field-family-intermediate-bases.md), three intermediate STI parents factor shared validation behavior out of `Field::Base`:
1329
+
1330
+ - **`TypedEAV::Field::ValidatedString`** — parent of `Text`, `Email`, `Url`. Owns string-length and pattern-validation helpers including `max_gte_min_length` (which now covers Email/Url, not just Text).
1331
+ - **`TypedEAV::Field::RangeBounded`** — parent of `Integer`, `Decimal`, `Date`, `DateTime` (and `Percentage < Decimal`). Owns range-validation helpers including `validates :max, comparison: { greater_than_or_equal_to: :min }` (which now covers Date/DateTime, not just Integer/Decimal).
1332
+ - **`TypedEAV::Field::Optionable`** — a Rails concern included by `Select` and `MultiSelect`. Owns the public-facing sorted `allowed_values` reader and the option-inclusion validators.
1333
+
1334
+ `Color`, `Boolean`, `Json`, and the array field types (`TextArray`, `IntegerArray`, `DecimalArray`, `DateArray`) remain direct children of `Field::Base`. See §[Family intermediate bases](#family-intermediate-bases-extension-points) for extension examples.
1335
+
1336
+ ### Scope tuple normalization: `ScopeTuple`
1337
+
1338
+ `TypedEAV::ScopeTuple` (`lib/typed_eav/scope_tuple.rb`, ~120 LOC) is the canonical source of truth for the `(scope, parent_scope)` partition tuple. It provides:
1339
+
1340
+ - `normalize_permissive(scope)` — coerces input to a tuple; tolerates bare scalars (used by `with_scope`, `normalize_scope`, `Field#validate_parent_scope_invariant`).
1341
+ - `normalize_strict(scope)` — same shape, but raises on bare-scalar input (used by `current_scope`; preserves Phase-1's asymmetric contract that `Config.scope_resolver` must return a tuple).
1342
+ - `invariant_satisfied?(scope, parent_scope)` — Boolean check for the orphan-parent invariant (`parent_scope` set without `scope` = invalid).
1343
+
1344
+ Each calling site keeps its own response policy (raise / AR error / silent narrow) using the Boolean return — `ScopeTuple` is a predicate, not an enforcer.
1345
+
1346
+ ### Partition tuple helpers: `Partition`
1347
+
1348
+ `TypedEAV::Partition` (`lib/typed_eav/partition.rb`, ~100 LOC) owns the `(entity_type, scope, parent_scope)` precedence rules:
1349
+
1350
+ - `definitions_by_name(model, scope, parent_scope)` — returns the field-definitions map for a single resolved partition.
1351
+ - `definitions_multimap_by_name(model)` — returns the cross-partition multimap used by `unscoped { }` blocks.
1352
+ - `visible_fields(model, scope, parent_scope)` / `visible_sections(...)` — scope-respecting field/section iteration with the orphan-parent invariant inlined via `ScopeTuple.invariant_satisfied?`.
1353
+
1354
+ The definitions helpers used to live as class methods on `HasTypedEav` before 0.3.0. They moved to `Partition` per [ADR-0002](docs/adr/0002-entity-query-orchestration.md) because they describe the partition domain, not the macro.
1355
+
1356
+ ### Events: `EventDispatcher`
1357
+
1358
+ `TypedEAV::EventDispatcher` (`lib/typed_eav/event_dispatcher.rb`, ~150 LOC) is the broker for `on_value_change` and `on_field_change` callbacks. Per [ADR-0003](docs/adr/0003-keep-event-dispatcher-broker.md), it intentionally stays a broker rather than getting absorbed into either `Value` or `Field` — its multi-publisher / multi-subscriber shape doesn't belong on either model. See §[Event hooks](#event-hooks) for the public callback contract.
1359
+
1360
+ ### Schema portability and CSV: independent modules
1361
+
1362
+ `TypedEAV::SchemaPortability` and `TypedEAV::CSVMapper` (Phase-6 modules) are deliberately decoupled from the core read/write path per [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md). They depend on the public `has_typed_eav` macro surface, never on internal modules.
1363
+
1124
1364
  ## License
1125
1365
 
1126
1366
  MIT