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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -0
- data/README.md +165 -47
- data/app/models/typed_eav/field/base.rb +28 -159
- data/app/models/typed_eav/field/currency.rb +54 -20
- data/app/models/typed_eav/field/date.rb +16 -1
- data/app/models/typed_eav/field/date_time.rb +16 -1
- data/app/models/typed_eav/field/decimal.rb +9 -1
- data/app/models/typed_eav/field/email.rb +13 -16
- data/app/models/typed_eav/field/integer.rb +6 -1
- data/app/models/typed_eav/field/long_text.rb +17 -1
- data/app/models/typed_eav/field/multi_select.rb +12 -9
- data/app/models/typed_eav/field/optionable.rb +59 -0
- data/app/models/typed_eav/field/percentage.rb +6 -6
- data/app/models/typed_eav/field/range_bounded.rb +71 -0
- data/app/models/typed_eav/field/select.rb +9 -10
- data/app/models/typed_eav/field/text.rb +11 -29
- data/app/models/typed_eav/field/url.rb +14 -16
- data/app/models/typed_eav/field/validated_string.rb +87 -0
- data/app/models/typed_eav/value.rb +9 -9
- data/lib/typed_eav/bulk_read.rb +124 -0
- data/lib/typed_eav/engine.rb +1 -1
- data/lib/typed_eav/entity_query.rb +186 -0
- data/lib/typed_eav/field/typed_storage.rb +205 -0
- data/lib/typed_eav/filter_query.rb +148 -0
- data/lib/typed_eav/has_typed_eav/instance_methods.rb +253 -0
- data/lib/typed_eav/has_typed_eav.rb +29 -793
- data/lib/typed_eav/partition.rb +51 -11
- data/lib/typed_eav/query_builder.rb +6 -7
- data/lib/typed_eav/scope_tuple.rb +116 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioning/subscriber.rb +7 -6
- data/lib/typed_eav.rb +23 -64
- metadata +11 -5
- data/lib/typed_eav/column_mapping.rb +0 -110
- data/lib/typed_eav/currency_storage_contract.rb +0 -46
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d06c0178a808cf7b67cf2b6970654fb922b02f9fa7a808eba6595f0aa3f1a38f
|
|
4
|
+
data.tar.gz: 456c0c15222d5ede4e12778ba7c95d0977cc726ecd0506141180badceb7c395b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
-
|
|
566
|
-
|
|
567
|
-
- `
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
- `
|
|
572
|
-
- `
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
|
593
|
-
value_record[
|
|
594
|
-
value_record[
|
|
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 =
|
|
725
|
+
default = default_value
|
|
599
726
|
return unless default.is_a?(Hash)
|
|
600
727
|
|
|
601
|
-
value_record[
|
|
602
|
-
value_record[
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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)
|
|
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`, `
|
|
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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# and the partition-aware backfill all live here together
|
|
10
|
-
# scope, parent_scope) partition
|
|
11
|
-
#
|
|
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::
|
|
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
|
-
# ──
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
# column
|
|
254
|
-
#
|
|
255
|
-
#
|
|
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`.
|
|
446
|
-
#
|
|
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
|
|
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
|