typed_eav 0.2.0 → 0.3.0

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 +119 -0
  3. data/README.md +165 -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 +11 -5
  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: baca2cc8536abd74f256197ca9204cab2cd552a1935be48c87cb80fa914ac8e6
4
- data.tar.gz: 3f61937a75ab864caa261359104cf4309fee8d8215b5603fbbd98f9be2c93f11
3
+ metadata.gz: d06c0178a808cf7b67cf2b6970654fb922b02f9fa7a808eba6595f0aa3f1a38f
4
+ data.tar.gz: 456c0c15222d5ede4e12778ba7c95d0977cc726ecd0506141180badceb7c395b
5
5
  SHA512:
6
- metadata.gz: ec9cfdb735c0820997082e991b07ed3d204bc362d40f0c85495a92b91320470c1ed89906d75a824573256f545d886df5a4ecf37ef4f866be33ccfc6e9d49c88e
7
- data.tar.gz: 1c575620e89da9da387c02a92d891288a7cf9558c6296008afe38081ce65f5716c385e2ad4c7eb4caecf38b376bc3f61a1437c9c15d9754657b2c7f45f328741
6
+ metadata.gz: 720454235c352fe0151eb533c53d172549e35e3920ae9ff730b66d8781ae3bf7cf1763afdd445cb0f36f559a21758451d336c640230c5741d58a055fd3c8c7db
7
+ data.tar.gz: 91548d07b441db3f7145b2645264161a0bd0294f5bc466c045f41fef98ea8f6dd240548beb4657c59343ca337b5cb7ac7faef5e6749211634fb812034e125567
data/CHANGELOG.md CHANGED
@@ -5,6 +5,123 @@ 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.0] - 2026-05-25
9
+
10
+ Pre-1.0 architecture cleanup arc (issues #9–#13). No public-API breakage
11
+ for host AR models or registered custom field types; behavior changes are
12
+ limited to two latent-bug fixes (now raised at field-save) and one
13
+ internal helper relocation (see "Changed" below). Anchored by ADRs
14
+ 0001–0005.
15
+
16
+ ### Added
17
+
18
+ - New `TypedEAV::Field::TypedStorage` concern (auto-included on
19
+ `Field::Base`) collapses the prior storage stack into three paired
20
+ override points: `read_value(record)`, `write_value(record, casted)`,
21
+ `apply_default(record)`. Custom multi-cell field types now extend
22
+ `Field::Base` directly and override only these methods. See README
23
+ §"Multi-cell field types" and ADR-0001 (issue #9).
24
+ - New top-level `TypedEAV::ScopeTuple` module exposes the
25
+ `[scope, parent_scope]` normalization surface: `normalize_permissive`,
26
+ `normalize_strict`, and `invariant_satisfied?`. Used by `Partition`,
27
+ `TypedEAV.with_scope`, `Config#resolve_scope`, and the query path
28
+ (issue #10).
29
+ - New top-level query objects extracted from `HasTypedEAV`:
30
+ `TypedEAV::EntityQuery` (class-method orchestration on host AR models),
31
+ `TypedEAV::FilterQuery` (multi-filter SQL composition for
32
+ `where_typed_eav` / `with_field`), and `TypedEAV::BulkRead` (bulk
33
+ per-record reads via `eav_values_for`). See ADR-0002 (issue #11).
34
+ - New field family intermediate bases collapse per-leaf duplication:
35
+ - `TypedEAV::Field::ValidatedString` — min/max-length + regex-pattern
36
+ validation surface for `string_value`-backed types (parent of
37
+ `Email` and `Url`).
38
+ - `TypedEAV::Field::RangeBounded` — min/max-bound validation helpers
39
+ for comparable single-value types (parent of `Integer`, `Decimal`,
40
+ `Date`, `DateTime`).
41
+ - `TypedEAV::Field::Optionable` — concern (not parent) for types that
42
+ draw values from a `Field::Option` set; included by `Select` and
43
+ `MultiSelect`.
44
+
45
+ See README §"Family intermediate bases (extension points)" and
46
+ ADR-0004 (issue #12).
47
+
48
+ ### Changed
49
+
50
+ - **Internal helper move.** `TypedEAV::HasTypedEAV.definitions_by_name`
51
+ and `TypedEAV::HasTypedEAV.definitions_multimap_by_name` moved to
52
+ `TypedEAV::Partition.definitions_by_name` /
53
+ `TypedEAV::Partition.definitions_multimap_by_name`. These helpers were
54
+ technically callable from application code but not documented;
55
+ partition-tuple precedence is a partition concept and the new home
56
+ reflects that. External callers (if any) should update the call site.
57
+ See ADR-0002 (issue #11).
58
+ - `TypedEAV::HasTypedEAV` is now a slim macro module
59
+ (`lib/typed_eav/has_typed_eav.rb`) that delegates to a per-instance
60
+ methods file (`lib/typed_eav/has_typed_eav/instance_methods.rb`) plus
61
+ the new `EntityQuery` / `FilterQuery` / `BulkRead` objects. Public
62
+ class-method and instance-method signatures on host AR models are
63
+ unchanged. See ADR-0002 (issue #11).
64
+ - Field validation now runs paired-bound checks at field-save time, not
65
+ only at value-write time:
66
+ - `Field::Email` / `Field::Url` (via `ValidatedString`) reject
67
+ `max_length < min_length` when the field record is saved.
68
+ - `Field::Date` / `Field::DateTime` (via `RangeBounded` leaves) reject
69
+ inverted `min_date`/`max_date` (and `min_datetime`/`max_datetime`)
70
+ bounds when the field record is saved.
71
+
72
+ Both were latent bugs prior to v0.3.0 — the bound mismatch was only
73
+ surfaced when a `Value` was written. Authors of custom field types
74
+ that store inverted bounds will now see the validation fail earlier.
75
+ See ADR-0004 (issue #12).
76
+
77
+ ### Removed
78
+
79
+ - `TypedEAV::Field::FieldStorageContract`,
80
+ `TypedEAV::Field::CurrencyStorageContract`, and
81
+ `TypedEAV::Field::ColumnMapping` are deleted; their surface lives on
82
+ `Field::TypedStorage`. ADR-0001 (issue #9).
83
+ - `TypedEAV::Partition.validate_tuple!` is deleted; callers use
84
+ `TypedEAV::ScopeTuple.normalize_strict` directly. Issue #10.
85
+
86
+ ### Internal
87
+
88
+ - `TypedEAV::EventDispatcher` is retained as the synchronous broker
89
+ between `TypedEAV::Hooks` and `ActiveSupport::Notifications`. The
90
+ cleanup arc explicitly considered collapsing it and rejected that:
91
+ the broker is the seam where event-name normalization and the
92
+ `notifications: false` opt-out live. See ADR-0003.
93
+ - The Phase-6 modules — `TypedEAV::BulkWrite`,
94
+ `TypedEAV::Importers::CSVMapper`, and
95
+ `TypedEAV::SchemaPortability::*` — remain independent top-level
96
+ modules. The cleanup arc explicitly considered consolidating them
97
+ under a single `TypedEAV::Operations` namespace and rejected that:
98
+ the modules share no internal contract and the namespace would be
99
+ cosmetic. See ADR-0005.
100
+ - Cyclomatic-complexity rubocop disables that previously masked the
101
+ `HasTypedEAV` mega-module are gone — the split files clear the
102
+ default complexity thresholds.
103
+
104
+ ### References
105
+
106
+ - Issue #9 — `Field::TypedStorage` concern.
107
+ - Issue #10 — `ScopeTuple` extraction.
108
+ - Issue #11 — `EntityQuery` / `FilterQuery` / `BulkRead` split.
109
+ - Issue #12 — Field family intermediate bases.
110
+ - Issue #13 — release coordination.
111
+ - ADR-0001 — collapse field storage stack.
112
+ - ADR-0002 — split `HasTypedEAV` into query objects.
113
+ - ADR-0003 — retain `EventDispatcher` as broker.
114
+ - ADR-0004 — field family intermediate bases.
115
+ - ADR-0005 — keep Phase-6 modules independent.
116
+
117
+ ## [0.2.1] - 2026-05-08
118
+
119
+ Metadata-only release.
120
+
121
+ ### Changed
122
+
123
+ - Updated the RubyGems package author metadata to `dchuk`.
124
+
8
125
  ## [0.2.0] - 2026-04-29
9
126
 
10
127
  Two-level scope partitioning. Field and section definitions now partition on
@@ -88,5 +205,7 @@ worked examples.
88
205
 
89
206
  Initial release.
90
207
 
208
+ [0.3.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.3.0
209
+ [0.2.1]: https://github.com/dchuk/typed_eav/releases/tag/v0.2.1
91
210
  [0.2.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.2.0
92
211
  [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
590
704
  end
591
705
 
592
- def write(value_record, casted)
593
- value_record[:decimal_value] = casted&.fetch(:amount, nil)
594
- value_record[:string_value] = casted&.fetch(:currency, nil)
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 }
712
+ end
713
+
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
 
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
4
-
5
3
  module TypedEAV
6
4
  module Field
7
- # rubocop:disable Metrics/ClassLength -- Field::Base is the central STI parent: associations,
8
- # validations, cascade dispatch, partition-aware ordering helpers, default-value handling,
9
- # and the partition-aware backfill all live here together because they share the (entity_type,
10
- # scope, parent_scope) partition contract. Splitting into concerns would scatter that contract
11
- # and obscure the cross-cutting invariants the validators and helpers enforce together.
5
+ # Field::Base is the central STI parent: associations, validations,
6
+ # cascade dispatch, partition-aware ordering helpers, default-value
7
+ # handling, and the partition-aware backfill all live here together
8
+ # because they share the (entity_type, scope, parent_scope) partition
9
+ # contract. Validation helpers that were previously co-located (length /
10
+ # pattern / range / option-inclusion) have moved down to the new family
11
+ # bases (`Field::ValidatedString`, `Field::RangeBounded`,
12
+ # `Field::Optionable`) per ADR-0004; `validate_array_size` stays here
13
+ # because its callers span unrelated families.
12
14
  class Base < ApplicationRecord
13
15
  self.table_name = "typed_eav_fields"
14
16
 
15
- include TypedEAV::ColumnMapping
17
+ include TypedEAV::Field::TypedStorage
16
18
 
17
19
  # ── Associations ──
18
20
 
@@ -244,84 +246,18 @@ module TypedEAV
244
246
  [raw, false]
245
247
  end
246
248
 
247
- # ── Phase 05 multi-cell extension points ──
248
- #
249
- # These three instance methods are the field-side surface that resolves
250
- # Value#value semantics, the write path, and the default-application
251
- # path. Single-cell field types (every built-in as of Phase 04) inherit
252
- # the defaults below and behave identically to the pre-Phase-05 direct-
253
- # column-access shape.
254
- #
255
- # Multi-cell field types (Phase 05: Currency stores `{amount, currency}`
256
- # across decimal_value + string_value) override these to compose /
257
- # unpack the logical value across multiple physical columns. The
258
- # dispatch keeps Value#value, Value#value=, and Value#apply_field_default
259
- # oblivious to multi-cell — they always go through the field, so adding
260
- # new multi-cell types in the future requires no Value-side changes.
261
- #
262
- # IMPORTANT: read_value, write_value, and apply_default_to are paired.
263
- # Currency overrides ALL THREE — overriding only one creates an
264
- # asymmetry where reads see the multi-cell shape but writes / defaults
265
- # populate only one column (or vice versa).
266
-
267
- # Returns the logical value for this field as stored on the given
268
- # Value record. Default reads `value_record[self.class.value_column]`.
269
- # Override in multi-cell field types to compose a hash from multiple
270
- # columns (e.g., Field::Currency returns
271
- # `{amount: r[:decimal_value], currency: r[:string_value]}`).
272
- #
273
- # Called from Value#value. The Value#value `return nil unless field`
274
- # guard runs before this method, so `self` is always set.
275
- def read_value(value_record)
276
- value_record[self.class.value_column]
277
- end
278
-
279
- # Writes a casted value to the given Value record. Default writes
280
- # `value_record[self.class.value_column] = casted`. Override in multi-
281
- # cell types to unpack a composite casted value into multiple columns
282
- # (e.g., Field::Currency unpacks `{amount: BigDecimal, currency: String}`
283
- # into decimal_value + string_value).
284
- #
285
- # Called from Value#value=. The cast invariant is preserved: `casted`
286
- # is whatever the field's `cast(raw)` returned as the first element.
287
- # For single-cell types that's a scalar; for Currency it's a Hash.
288
- # Without this dispatch, a Currency cast result (a Hash) would be
289
- # written to a single typed column, raising TypeMismatch at save time.
290
- def write_value(value_record, casted)
291
- value_record[self.class.value_column] = casted
292
- end
293
-
294
- # Writes this field's configured default to the given Value record.
295
- # Default writes `value_record[self.class.value_column] = default_value`,
296
- # bypassing Value#value= to avoid re-casting an already-cast default
297
- # (default_value is cast at field save time via validate_default_value).
298
- # Override in multi-cell types to populate multiple columns from a
299
- # composite default (e.g., Field::Currency unpacks `default_value`'s
300
- # `{amount: ..., currency: ...}` hash into decimal_value + string_value).
301
- #
302
- # Called from Value#apply_field_default in two contexts:
303
- # 1. Initial value assignment when no `value:` kwarg was passed
304
- # (UNSET_VALUE sentinel resolution path).
305
- # 2. Pending-value resolution (apply_pending_value branch where
306
- # @pending_value was UNSET_VALUE and the field arrived later).
307
- def apply_default_to(value_record)
308
- value_record[self.class.value_column] = default_value
309
- end
249
+ # ── Multi-cell extension points ──
250
+ #
251
+ # The three override-point instance methods (`read_value`,
252
+ # `write_value`, `apply_default`) and the concrete snapshot helpers
253
+ # (`value_changed?`, `before_snapshot`, `after_snapshot`) live in the
254
+ # `Field::TypedStorage` concern included above. Field types that span
255
+ # more than one typed column override all three to compose / unpack the
256
+ # logical value across multiple cells; `Field::Currency` is the
257
+ # canonical example.
310
258
 
311
259
  # ── Introspection ──
312
260
 
313
- def self.storage_contract_class(contract_class = nil)
314
- if contract_class
315
- @storage_contract_class = contract_class
316
- else
317
- @storage_contract_class || TypedEAV::FieldStorageContract
318
- end
319
- end
320
-
321
- def storage_contract
322
- @storage_contract ||= self.class.storage_contract_class.new(self)
323
- end
324
-
325
261
  def field_type_name
326
262
  self.class.name.demodulize.underscore
327
263
  end
@@ -442,8 +378,14 @@ module TypedEAV
442
378
  #
443
379
  # Default no-op. Subclasses override to enforce their constraints
444
380
  # (length, range, pattern, option inclusion, array size, etc.) and
445
- # add errors to `record.errors`. Shared helpers below (validate_length,
446
- # validate_pattern, validate_range, etc.) are available to subclasses.
381
+ # add errors to `record.errors`. Family-specific helpers live on the
382
+ # appropriate family base / concern (`ValidatedString` provides
383
+ # `validate_length` / `validate_pattern`; `RangeBounded` provides
384
+ # `validate_range` / `validate_date_range` / `validate_datetime_range`;
385
+ # `Optionable` provides `validate_option_inclusion` /
386
+ # `validate_multi_option_inclusion`). `validate_array_size` stays
387
+ # here because its callers (MultiSelect via Optionable AND
388
+ # IntegerArray directly) don't share a family.
447
389
  def validate_typed_value(record, val)
448
390
  # no-op by default
449
391
  end
@@ -454,77 +396,6 @@ module TypedEAV
454
396
  options&.with_indifferent_access || {}
455
397
  end
456
398
 
457
- def validate_length(record, val)
458
- opts = options_hash
459
- str = val.to_s
460
- if opts[:min_length] && str.length < opts[:min_length].to_i
461
- record.errors.add(:value, :too_short, count: opts[:min_length])
462
- end
463
- return unless opts[:max_length] && str.length > opts[:max_length].to_i
464
-
465
- record.errors.add(:value, :too_long, count: opts[:max_length])
466
- end
467
-
468
- def validate_pattern(record, val)
469
- opts = options_hash
470
- pattern = opts[:pattern]
471
- return if pattern.blank?
472
-
473
- matched = Timeout.timeout(1) { Regexp.new(pattern).match?(val.to_s) }
474
- record.errors.add(:value, :invalid) unless matched
475
- rescue RegexpError
476
- record.errors.add(:value, "has an invalid pattern configured")
477
- rescue Timeout::Error
478
- record.errors.add(:value, "pattern validation timed out")
479
- end
480
-
481
- def validate_range(record, val)
482
- opts = options_hash
483
- record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min]) if opts[:min] && val < opts[:min].to_d
484
- return unless opts[:max] && val > opts[:max].to_d
485
-
486
- record.errors.add(:value, :less_than_or_equal_to, count: opts[:max])
487
- end
488
-
489
- def validate_date_range(record, val)
490
- opts = options_hash
491
- if opts[:min_date]
492
- min = ::Date.parse(opts[:min_date])
493
- record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_date]) if val < min
494
- end
495
- if opts[:max_date]
496
- max = ::Date.parse(opts[:max_date])
497
- record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_date]) if val > max
498
- end
499
- rescue ::Date::Error
500
- record.errors.add(:base, "field has invalid date configuration")
501
- end
502
-
503
- def validate_datetime_range(record, val)
504
- opts = options_hash
505
- if opts[:min_datetime]
506
- min = ::Time.zone.parse(opts[:min_datetime])
507
- record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_datetime]) if val < min
508
- end
509
- if opts[:max_datetime]
510
- max = ::Time.zone.parse(opts[:max_datetime])
511
- record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_datetime]) if val > max
512
- end
513
- rescue ArgumentError
514
- record.errors.add(:base, "field has invalid datetime configuration")
515
- end
516
-
517
- def validate_option_inclusion(record, val)
518
- return if allowed_option_values.include?(val&.to_s)
519
-
520
- record.errors.add(:value, :inclusion)
521
- end
522
-
523
- def validate_multi_option_inclusion(record, val)
524
- invalid = Array(val).map(&:to_s) - allowed_option_values
525
- record.errors.add(:value, :inclusion) if invalid.any?
526
- end
527
-
528
399
  def validate_array_size(record, val)
529
400
  opts = options_hash
530
401
  arr = Array(val)
@@ -579,8 +450,7 @@ module TypedEAV
579
450
  # the same incoherent state as a literal NULL. Same reasoning for
580
451
  # `scope.present?`.
581
452
  def validate_parent_scope_invariant
582
- return if parent_scope.blank?
583
- return if scope.present?
453
+ return if TypedEAV::ScopeTuple.invariant_satisfied?(scope, parent_scope)
584
454
 
585
455
  errors.add(:parent_scope, "cannot be set when scope is blank")
586
456
  end
@@ -775,6 +645,5 @@ module TypedEAV
775
645
  TypedEAV::EventDispatcher.dispatch_field_change(self, change_type)
776
646
  end
777
647
  end
778
- # rubocop:enable Metrics/ClassLength
779
648
  end
780
649
  end