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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +134 -0
- data/README.md +287 -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 +10 -4
- 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: 66997bbaca41b0302fe7a86baf9b22233f2ade442c2255c3323a900245bd78fb
|
|
4
|
+
data.tar.gz: 90498aa57c720504bc09a34e62fab81d0377baaeab3ea2d0598317d6d5b38a93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
|
@@ -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
|