typed_eav 0.8.0 → 0.8.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 +25 -2
- data/README.md +79 -1710
- data/RELEASING.md +81 -0
- data/docs/adr/0001-collapse-column-mapping-stack.md +28 -0
- data/docs/adr/0002-entity-query-orchestration.md +33 -0
- data/docs/adr/0003-keep-event-dispatcher-broker.md +41 -0
- data/docs/adr/0004-field-family-intermediate-bases.md +49 -0
- data/docs/adr/0005-keep-phase-six-modules-independent.md +48 -0
- data/docs/adr/0006-include-missing-via-set-complement.md +77 -0
- data/docs/adr/0007-visibility-versus-mutation-relations.md +33 -0
- data/docs/adr/0008-partial-covering-scalar-indexes.md +83 -0
- data/docs/adr/0009-string-search-indexing.md +110 -0
- data/docs/adr/0010-planner-statistics-policy.md +109 -0
- data/docs/adr/0011-multi-filter-query-strategy.md +111 -0
- data/docs/adr/0012-cross-scope-administrative-query-policy.md +76 -0
- data/docs/adr/0013-durable-versioning-and-field-deletion.md +125 -0
- data/docs/adr/index.md +101 -0
- data/docs/getting-started.md +79 -0
- data/docs/guides/architecture.md +125 -0
- data/docs/guides/bulk-operations.md +205 -0
- data/docs/guides/csv-import.md +88 -0
- data/docs/guides/development.md +73 -0
- data/docs/guides/events-and-versioning.md +360 -0
- data/docs/guides/fields.md +342 -0
- data/docs/guides/performance.md +107 -0
- data/docs/guides/queries.md +188 -0
- data/docs/guides/schema.md +134 -0
- data/docs/guides/scoping.md +254 -0
- data/docs/guides/upgrading.md +26 -0
- data/docs/guides/usage.md +259 -0
- data/docs/index.md +44 -0
- data/docs/maintaining.md +82 -0
- data/docs/reference/api.md +133 -0
- data/docs/reference/configuration.md +64 -0
- data/docs/reference/index.md +16 -0
- data/lib/typed_eav/version.rb +1 -1
- metadata +35 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Field types and validation"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Field types and validation
|
|
6
|
+
|
|
7
|
+
[Documentation home](../index.md)
|
|
8
|
+
|
|
9
|
+
## Field Types
|
|
10
|
+
|
|
11
|
+
| Type | Column | Ruby Type | Options |
|
|
12
|
+
|------|--------|-----------|---------|
|
|
13
|
+
| `Text` | `string_value` | String | `min_length`, `max_length`, `pattern` |
|
|
14
|
+
| `LongText` | `text_value` | String | `min_length`, `max_length` |
|
|
15
|
+
| `Integer` | `integer_value` | Integer | `min`, `max` |
|
|
16
|
+
| `Decimal` | `decimal_value` | BigDecimal | `min`, `max`, `precision_scale` |
|
|
17
|
+
| `Boolean` | `boolean_value` | Boolean | |
|
|
18
|
+
| `Date` | `date_value` | Date | `min_date`, `max_date` |
|
|
19
|
+
| `DateTime` | `datetime_value` | Time | `min_datetime`, `max_datetime` |
|
|
20
|
+
| `Select` | `string_value` | String | options via `TypedEAV::Option` |
|
|
21
|
+
| `MultiSelect` | `json_value` | Array | options via `TypedEAV::Option` |
|
|
22
|
+
| `IntegerArray` | `json_value` | Array | `min_size`, `max_size`, `min`, `max` |
|
|
23
|
+
| `DecimalArray` | `json_value` | Array | `min_size`, `max_size` |
|
|
24
|
+
| `TextArray` | `json_value` | Array | `min_size`, `max_size` |
|
|
25
|
+
| `DateArray` | `json_value` | Array | `min_size`, `max_size` |
|
|
26
|
+
| `Email` | `string_value` | String | auto-downcases, strips whitespace |
|
|
27
|
+
| `Url` | `string_value` | String | strips whitespace |
|
|
28
|
+
| `Color` | `string_value` | String | hex color values |
|
|
29
|
+
| `Json` | `json_value` | Hash/Array | arbitrary JSON |
|
|
30
|
+
| `Currency` | `decimal_value` + `string_value` | `{amount: BigDecimal, currency: String}` | `default_currency`, `allowed_currencies` |
|
|
31
|
+
| `Percentage` | `decimal_value` | BigDecimal (0..1 range) | `decimal_places`, `display_as: :fraction \| :percent` |
|
|
32
|
+
| `Image` | `string_value` (signed_id) + `:attachment` has_one_attached | String (Active Storage signed_id) | `allowed_content_types`, `max_size_bytes` |
|
|
33
|
+
| `File` | `string_value` (signed_id) + `:attachment` has_one_attached | String (Active Storage signed_id) | `allowed_content_types`, `max_size_bytes` |
|
|
34
|
+
| `Reference` | `integer_value` (FK) | Integer (target record ID) | `target_entity_type`, `target_scope` |
|
|
35
|
+
|
|
36
|
+
## Sections (Optional UI Grouping)
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
general = TypedEAV::Section.create!(
|
|
40
|
+
name: "General Info",
|
|
41
|
+
code: "general",
|
|
42
|
+
entity_type: "Contact",
|
|
43
|
+
sort_order: 1
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
social = TypedEAV::Section.create!(
|
|
47
|
+
name: "Social Media",
|
|
48
|
+
code: "social",
|
|
49
|
+
entity_type: "Contact",
|
|
50
|
+
sort_order: 2
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
TypedEAV::Field::Text.create!(
|
|
54
|
+
name: "twitter_handle",
|
|
55
|
+
entity_type: "Contact",
|
|
56
|
+
section: social
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Custom Field Types
|
|
61
|
+
|
|
62
|
+
Override `cast(raw)` to return a `[casted_value, invalid?]` tuple.
|
|
63
|
+
`invalid?` tells `Value#validate_value` whether to surface `:invalid`
|
|
64
|
+
(vs `:blank`) when raw input can't be coerced. For types that never
|
|
65
|
+
fail to coerce, always return `[value, false]`.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# app/models/fields/phone.rb
|
|
69
|
+
module Fields
|
|
70
|
+
class Phone < TypedEAV::Field::Base
|
|
71
|
+
value_column :string_value
|
|
72
|
+
operators :eq, :contains, :starts_with, :is_null, :is_not_null
|
|
73
|
+
|
|
74
|
+
def cast(raw)
|
|
75
|
+
# Strip everything but digits and +; never rejects as invalid
|
|
76
|
+
[raw&.to_s&.gsub(/[^\d+]/, ""), false]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Register it
|
|
82
|
+
TypedEAV.configure do |c|
|
|
83
|
+
c.register_field_type :phone, "Fields::Phone"
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Family intermediate bases (extension points)
|
|
88
|
+
|
|
89
|
+
`Field::Base` is the universal parent, but three intermediate family
|
|
90
|
+
bases collapse the most common per-leaf duplication. Pick the right
|
|
91
|
+
parent and you inherit the family's validation surface for free.
|
|
92
|
+
|
|
93
|
+
- **`TypedEAV::Field::ValidatedString`** — subclass when your custom
|
|
94
|
+
type stores in `string_value` and wants a min/max-length + regex-pattern
|
|
95
|
+
validation surface. Inherits `value_column :string_value`,
|
|
96
|
+
`store_accessor :options, :min_length, :max_length, :pattern`,
|
|
97
|
+
numericality validators on `min_length` / `max_length`, a
|
|
98
|
+
`max_gte_min_length` guard that rejects inverted bounds at field-save,
|
|
99
|
+
and a `validate_pattern_syntax` guard that rejects bad regexes at
|
|
100
|
+
field-save. The default `validate_typed_value(record, val)` runs
|
|
101
|
+
`validate_length` plus `validate_pattern if pattern.present?`. Override
|
|
102
|
+
it and call `super` to layer on a format-specific check (the built-in
|
|
103
|
+
`Field::Email` / `Field::Url` are the canonical pattern).
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
class Fields::Slug < TypedEAV::Field::ValidatedString
|
|
107
|
+
SLUG_FORMAT = /\A[a-z0-9-]+\z/
|
|
108
|
+
|
|
109
|
+
def cast(raw)
|
|
110
|
+
[raw&.to_s&.strip&.downcase, false]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_typed_value(record, val)
|
|
114
|
+
super # length + pattern from the family base
|
|
115
|
+
record.errors.add(:value, "is not a valid slug") unless SLUG_FORMAT.match?(val.to_s)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- **`TypedEAV::Field::RangeBounded`** — subclass when your custom type
|
|
121
|
+
stores a single comparable value (numeric or temporal) constrained by
|
|
122
|
+
a min/max bound. Each leaf still declares its own `value_column` and
|
|
123
|
+
its own `store_accessor` (key names vary by family member: `:min`/`:max`
|
|
124
|
+
for numeric; `:min_date`/`:max_date` for date;
|
|
125
|
+
`:min_datetime`/`:max_datetime` for datetime). The family base
|
|
126
|
+
provides protected `validate_range` / `validate_date_range` /
|
|
127
|
+
`validate_datetime_range` helpers. Each leaf should pair its
|
|
128
|
+
`store_accessor` with the macro
|
|
129
|
+
`validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min`
|
|
130
|
+
(or the analogous form for the leaf's key names) so inverted bounds
|
|
131
|
+
fail at field-save.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
class Fields::Score < TypedEAV::Field::RangeBounded
|
|
135
|
+
value_column :integer_value
|
|
136
|
+
|
|
137
|
+
store_accessor :options, :min, :max
|
|
138
|
+
validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
|
|
139
|
+
|
|
140
|
+
def cast(raw)
|
|
141
|
+
raw.nil? ? [nil, false] : [Integer(raw.to_s, exception: false), raw.to_s.empty? ? false : true]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_typed_value(record, val)
|
|
145
|
+
validate_range(record, val)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- **`TypedEAV::Field::Optionable`** — `include` this concern when your
|
|
151
|
+
custom type's valid values are drawn from a `Field::Option` set.
|
|
152
|
+
Provides `optionable? = true`, a public-facing sorted
|
|
153
|
+
`allowed_values` helper, and protected
|
|
154
|
+
`validate_option_inclusion` / `validate_multi_option_inclusion`
|
|
155
|
+
helpers. Mixin (not inheritance) because option-set field types may
|
|
156
|
+
use different `value_column`s — the built-in `Field::Select` stores in
|
|
157
|
+
`string_value` while `Field::MultiSelect` stores in `json_value`, and
|
|
158
|
+
both stay as direct children of `Field::Base`.
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
class Fields::Tag < TypedEAV::Field::Base
|
|
162
|
+
include TypedEAV::Field::Optionable
|
|
163
|
+
|
|
164
|
+
value_column :string_value
|
|
165
|
+
operators :eq, :not_eq, :is_null, :is_not_null
|
|
166
|
+
|
|
167
|
+
def cast(raw)
|
|
168
|
+
[raw&.to_s, false]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_typed_value(record, val)
|
|
172
|
+
validate_option_inclusion(record, val)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The rule of thumb: subclass an intermediate family base when the new
|
|
178
|
+
field type shares its storage and validation surface with the family;
|
|
179
|
+
include `Optionable` when it draws values from an option set; subclass
|
|
180
|
+
`Field::Base` directly (as the `Phone` example above does) when none of
|
|
181
|
+
the family surfaces fit. `validate_array_size` lives on `Field::Base`
|
|
182
|
+
itself — its callers span unrelated families.
|
|
183
|
+
|
|
184
|
+
### Multi-cell field types
|
|
185
|
+
|
|
186
|
+
External field types may store their logical value across multiple typed
|
|
187
|
+
columns. The entire storage surface lives directly on `Field::Base` via
|
|
188
|
+
the `Field::TypedStorage` concern, so a custom multi-cell type is just a
|
|
189
|
+
`Field::Base` subclass that overrides three instance methods.
|
|
190
|
+
|
|
191
|
+
**Class-level DSL** (declared at class load time):
|
|
192
|
+
|
|
193
|
+
- `value_column :col` – single-cell sugar; declares the primary cell.
|
|
194
|
+
- `value_columns :a, :b, ...` – plural form for multi-cell types. The
|
|
195
|
+
primary cell is `value_columns.first`. Both forms share storage;
|
|
196
|
+
`value_column` and `value_columns` are interchangeable getters/setters.
|
|
197
|
+
- `operators :eq, :gt, ...` – restrict the supported operator set.
|
|
198
|
+
- `self.operator_column(op)` – override to route different operators to
|
|
199
|
+
different cells. Defaults to `value_columns.first`.
|
|
200
|
+
|
|
201
|
+
**Override-point instance methods** (the entire extension surface for
|
|
202
|
+
multi-cell types):
|
|
203
|
+
|
|
204
|
+
- `read_value(record)` – compose the logical value from the cells.
|
|
205
|
+
- `write_value(record, casted)` – unpack the casted value across cells.
|
|
206
|
+
- `apply_default(record)` – populate cells from `default_value`.
|
|
207
|
+
|
|
208
|
+
The defaults target `value_columns.first`, so single-cell field types
|
|
209
|
+
keep working without overrides. The three methods are paired – override
|
|
210
|
+
all three or your reads will see a multi-cell shape that writes / defaults
|
|
211
|
+
cannot produce.
|
|
212
|
+
|
|
213
|
+
**Concrete snapshot helpers** (NOT overridable; derived from
|
|
214
|
+
`value_columns`):
|
|
215
|
+
|
|
216
|
+
- `value_changed?(record)` – true iff any cell saw a saved change.
|
|
217
|
+
- `before_snapshot(record, change_type)` / `after_snapshot(record, change_type)`
|
|
218
|
+
– per-cell hashes keyed by string column names; powers the versioning
|
|
219
|
+
jsonb shape.
|
|
220
|
+
|
|
221
|
+
Custom multi-cell type example (matches the built-in `Field::Currency`):
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class Fields::Money < TypedEAV::Field::Base
|
|
225
|
+
AMOUNT_COLUMN = :decimal_value
|
|
226
|
+
CURRENCY_COLUMN = :string_value
|
|
227
|
+
|
|
228
|
+
value_columns AMOUNT_COLUMN, CURRENCY_COLUMN
|
|
229
|
+
operators :eq, :gt, :lt, :gteq, :lteq, :between, :currency_eq, :is_null, :is_not_null
|
|
230
|
+
|
|
231
|
+
def self.operator_column(operator)
|
|
232
|
+
operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def read_value(value_record)
|
|
236
|
+
amount = value_record[AMOUNT_COLUMN]
|
|
237
|
+
currency = value_record[CURRENCY_COLUMN]
|
|
238
|
+
return nil if amount.nil? && currency.nil?
|
|
239
|
+
|
|
240
|
+
{ amount: amount, currency: currency }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def write_value(value_record, casted)
|
|
244
|
+
if casted.nil?
|
|
245
|
+
value_record[AMOUNT_COLUMN] = nil
|
|
246
|
+
value_record[CURRENCY_COLUMN] = nil
|
|
247
|
+
else
|
|
248
|
+
value_record[AMOUNT_COLUMN] = casted[:amount]
|
|
249
|
+
value_record[CURRENCY_COLUMN] = casted[:currency]
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def apply_default(value_record)
|
|
254
|
+
default = default_value
|
|
255
|
+
return unless default.is_a?(Hash)
|
|
256
|
+
|
|
257
|
+
value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"]
|
|
258
|
+
value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"]
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The built-in `Field::Currency` is the canonical multi-cell consumer of
|
|
264
|
+
these extension points and reads as a normal `Field::Base` subclass with
|
|
265
|
+
exactly three method overrides.
|
|
266
|
+
|
|
267
|
+
### Built-in field types
|
|
268
|
+
|
|
269
|
+
- **`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.
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
Contact.where_typed_eav(name: "price", op: :currency_eq, value: "USD")
|
|
273
|
+
Contact.where_typed_eav(name: "price", op: :between, value: [50, 150])
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
- **`Percentage`:** A `Field::Decimal` subclass storing the underlying fraction in 0..1 (inclusive). The `:percent` representation is a format-time concern — call `field.format(value)` with `display_as: :percent` to render `0.75` as `"75.0%"`. Options: `decimal_places` (Integer >= 0, default 2; format-time precision only — does NOT alter what's stored in `decimal_value`), `display_as` (`:fraction` default, or `:percent`). Validation: out-of-range values (e.g., `1.5`) fail with the message `"must be between 0.0 and 1.0"`. Storage and operator semantics inherit from `Field::Decimal`.
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
pf = TypedEAV::Field::Percentage.create!(
|
|
280
|
+
name: "discount", entity_type: "Order", scope: tenant_id,
|
|
281
|
+
options: { display_as: :percent, decimal_places: 1 },
|
|
282
|
+
)
|
|
283
|
+
pf.format(BigDecimal("0.755")) # => "75.5%"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- **`Image`:** Active Storage-backed field type. Stores the attached blob's `signed_id` (a String) in `string_value`. Operators: `:eq`, `:is_null`, `:is_not_null`. Options: `allowed_content_types` (Array of strings; supports exact matches like `"image/png"` and `image/*` family wildcards), `max_size_bytes` (Integer; nil disables the cap). The single `:attachment` has_one_attached association is declared on `TypedEAV::Value` at engine boot when Active Storage is loaded; otherwise `Field::Image#cast` raises `NotImplementedError` with an actionable install message. The `:attachment` association is shared with `Field::File` — Image vs File is a class-identity distinction (used by the `on_image_attached` hook), not a separate association.
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
field = TypedEAV::Field::Image.create!(
|
|
290
|
+
name: "avatar", entity_type: "Contact",
|
|
291
|
+
options: { allowed_content_types: %w[image/png image/jpeg image/webp], max_size_bytes: 5_000_000 },
|
|
292
|
+
)
|
|
293
|
+
value = TypedEAV::Value.create!(entity: contact, field: field)
|
|
294
|
+
value.attachment.attach(io: file_io, filename: "avatar.png", content_type: "image/png")
|
|
295
|
+
value.update!(string_value: value.attachment.blob.signed_id)
|
|
296
|
+
value.value # => the signed_id String
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
- **`File`:** Same shape as `Field::Image` but without image-specific semantics. Stores `signed_id` in `string_value`; same operator set; same options (`allowed_content_types`, `max_size_bytes`). The Image vs File distinction is by `value.field.class` at runtime — apps that want strict image-only validation set `allowed_content_types: ["image/*"]` on `Field::Image`; `Field::File` is a general-purpose attachment slot.
|
|
300
|
+
|
|
301
|
+
- **Active Storage dependency:** Lazy soft-detect via `defined?(::ActiveStorage::Blob)`. The gem does NOT add Active Storage as a hard dependency — apps that never use Image/File never need to install it. To use Image or File fields, add `gem "activestorage"` to your Gemfile (included in supported Rails versions via the `rails` meta-gem) and run `bin/rails active_storage:install` to create the `active_storage_blobs` / `active_storage_attachments` / `active_storage_variant_records` tables. The mirror precedent is `acts_as_tenant`, which is also soft-detected (see `Config::DEFAULT_SCOPE_RESOLVER`).
|
|
302
|
+
|
|
303
|
+
- **`on_image_attached` hook:** Fires from `after_commit` on `TypedEAV::Value` when a `Field::Image`-typed Value's attachment is added or replaced. Receives `(value, blob)`. Configure via `TypedEAV.configure { |c| c.on_image_attached = ->(v, b) { ... } }`. Hook ordering: runs AFTER versioning (Phase 4) and AFTER `on_value_change` (Phase 3) so it sees the persisted version row and the user-callback context. File attachments do NOT fire this hook — the name is image-specific by design. Use `on_value_change` for a generic value-mutation signal that covers File-typed Values too.
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
TypedEAV.configure do |c|
|
|
307
|
+
c.on_image_attached = ->(value, blob) {
|
|
308
|
+
ProcessImageJob.perform_later(value.id, blob.id)
|
|
309
|
+
}
|
|
310
|
+
end
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
- **`Reference`:** Foreign-key field type. Stores the target record's integer ID in `integer_value`. Operators: `:eq`, `:is_null`, `:is_not_null`, `:references` (explicit narrowing — does NOT inherit `:integer_value`'s `:gt`/`:lt`/`:between` defaults; arithmetic comparisons on FKs don't carry useful semantics). The `:references` operator accepts AR record instances OR Integer IDs at query time, normalizing via `field.cast` (a class-mismatched record routes to `base.none` rather than `:is_null`). Options: `target_entity_type` (REQUIRED — String class name of the target model, validated to constantize at field save), `target_scope` (OPTIONAL — when set, the field is REJECTED at save time if `target_entity_type` is not registered with `has_typed_eav scope_method:` (Gating Decision 2); when set with a scoped target, value-time validation rejects writes whose target's `typed_eav_scope` does not match `target_scope` via a `target_partition_matches?` helper structurally parallel to Phase 1's `entity_partition_axis_matches?` but on the target axis). Cross-scope safety mirrors the existing `Value#validate_field_scope_matches_entity` guard pattern applied to the target rather than the source.
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
rf = TypedEAV::Field::Reference.create!(
|
|
317
|
+
name: "manager", entity_type: "Contact", scope: tenant_id,
|
|
318
|
+
options: { target_entity_type: "Contact", target_scope: tenant_id },
|
|
319
|
+
)
|
|
320
|
+
TypedEAV::Value.create!(entity: alice, field: rf, value: bob) # accepts AR record
|
|
321
|
+
TypedEAV::Value.create!(entity: alice, field: rf, value: bob.id) # accepts Integer FK
|
|
322
|
+
Contact.where_typed_eav(name: "manager", op: :references, value: bob) # filter by record
|
|
323
|
+
Contact.where_typed_eav(name: "manager", op: :references, value: 42) # filter by FK
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
- **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.
|
|
327
|
+
|
|
328
|
+
## Validation Behavior
|
|
329
|
+
|
|
330
|
+
A few non-obvious contracts worth knowing about up front:
|
|
331
|
+
|
|
332
|
+
- **Required + blank**: `required: true` fields reject empty strings, whitespace-only strings, and arrays whose every element is nil/blank/whitespace.
|
|
333
|
+
- **Array all-or-nothing cast**: integer/decimal/date arrays mark the **whole** value invalid (stored as `nil`) when any element fails to cast. There is no silent partial — a failed form re-renders with the original input intact so the user can correct the bad element.
|
|
334
|
+
- **`Integer` array rejects fractional input**: `"1.9"` is rejected rather than truncated to `1`. Same rules as the scalar `Integer` field.
|
|
335
|
+
- **`Json` parses string input**: a JSON string posted from a form is parsed; parse failures surface as `:invalid` rather than being stored as the literal string.
|
|
336
|
+
- **`TextArray` does not support `:contains`**: it backs a jsonb column where SQL `LIKE` doesn't apply. Use `:any_eq` for "array contains element".
|
|
337
|
+
- **Orphaned values are skipped**: if a field row is deleted while values remain, `typed_eav_value` and `typed_eav_hash` silently skip the orphans rather than raising.
|
|
338
|
+
- **Cross-scope writes are rejected**: assigning a `Value` to a record whose `typed_eav_scope` doesn't match the field's `scope` adds a validation error on `:field`. The same guard covers the `parent_scope` axis.
|
|
339
|
+
- **Orphan-parent rows rejected**: a `Field` or `Section` row with `parent_scope` set but `scope` blank is invalid. The `Value`-side guard rejects cross-`(scope, parent_scope)` writes too.
|
|
340
|
+
- **Event hooks fire from `after_commit`**: the `on_value_change` and `on_field_change` callbacks fire after the database write is durable; their exceptions never break a save. See [Event hooks](events-and-versioning.md#event-hooks) for the full contract.
|
|
341
|
+
- **Versioning is opt-in**: When enabled (`TypedEAV.config.versioning = true` on the gem; `versioned: true` per host), every `:create` / `:update` / `:destroy` event on a Value writes an append-only audit row in `typed_eav_value_versions`. See [Versioning](events-and-versioning.md#versioning) for the full contract.
|
|
342
|
+
- **`label` is cosmetic, `name` is the machine key**: A field's optional `label` is free-text human display, independent of the slug `name`. Render via `display_name`, which returns `label` when present else `name.humanize`. `label` has no uniqueness or format constraints (only a 255-char max) and never affects ordering, lookup, partitioning, or rename detection — editing only `label` fires `on_field_change` with `:update`, never `:rename`. Existing rows (`label` NULL) render unchanged. Schema export round-trips the raw `label` (legacy payloads without a `label` key import as NULL); snapshot export carries the resolved `display_name`.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Storage and performance"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Storage and performance
|
|
6
|
+
|
|
7
|
+
[Documentation home](../index.md)
|
|
8
|
+
|
|
9
|
+
## Why Typed Columns?
|
|
10
|
+
|
|
11
|
+
JSONB is a useful fit when an application owns stable paths and wants expression B-tree indexes or GIN containment indexes. A single JSONB document is not inherently faster or slower than TypedEAV; the right choice depends on access patterns, selectivity, update shape, and operational constraints. For example, an application-owned expression index may support a stable path:
|
|
12
|
+
|
|
13
|
+
```sql
|
|
14
|
+
CAST(value_meta->>'const' AS bigint) = 42
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This can work well for stable, known paths. It does not provide the same column-level schema and typed-value contract as TypedEAV, and arbitrary paths still require application-owned index and validation decisions. GIN is useful for containment workloads; expression B-trees are useful for selected stable scalar paths.
|
|
18
|
+
|
|
19
|
+
TypedEAV stores values in native columns, so queries become:
|
|
20
|
+
|
|
21
|
+
```sql
|
|
22
|
+
WHERE integer_value = 42
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
TypedEAV supplies stable typed columns and ordinary per-type indexes. Range scans and sorts can use those indexes, while each Field casts and validates the operand according to its own semantics before the query reaches the typed column. Neither design is a universal storage winner; choose from measured workload fit.
|
|
26
|
+
|
|
27
|
+
### Optional trigram indexing for string search
|
|
28
|
+
|
|
29
|
+
TypedEAV keeps its partial-covering `text_pattern_ops` B-tree as the default
|
|
30
|
+
string index. Equality uses that B-tree, while `:starts_with`, `:contains`, and
|
|
31
|
+
`:ends_with` use `ILIKE`; `:not_contains` uses `NOT ILIKE`. The gem does not
|
|
32
|
+
require or install `pg_trgm` and does not create a trigram index automatically.
|
|
33
|
+
|
|
34
|
+
An application with frequent positive `ILIKE` searches containing at least
|
|
35
|
+
three useful characters may evaluate its own partial GIN index. This is a
|
|
36
|
+
workload decision: the representative benchmark used GIN for measured prefix,
|
|
37
|
+
contains, suffix, and escaped-literal patterns, but not for `NOT ILIKE` or
|
|
38
|
+
one/two-character probes. It does not prove that every positive pattern or
|
|
39
|
+
selectivity will benefit. A `lower(string_value) LIKE ...` expression index is
|
|
40
|
+
not equivalent to TypedEAV's public `ILIKE`, and the benchmark did not justify
|
|
41
|
+
GiST.
|
|
42
|
+
|
|
43
|
+
Application owners should check extension availability and deploy-role
|
|
44
|
+
privileges in preproduction, then create the extension and index in their own
|
|
45
|
+
migrations. Use nontransactional `CREATE INDEX CONCURRENTLY`, a stable
|
|
46
|
+
application-specific name, and workload-specific `EXPLAIN (ANALYZE, BUFFERS,
|
|
47
|
+
WAL, SETTINGS)` plus storage and write-WAL measurements. Rollback should drop
|
|
48
|
+
only the application-owned index concurrently; do not drop the database-wide
|
|
49
|
+
extension because other objects may share it. See
|
|
50
|
+
[ADR 0009](../adr/0009-string-search-indexing.md) and the
|
|
51
|
+
[benchmark guide](https://github.com/dchuk/typed_eav/blob/main/bench/README.md#phase-3-string-search-benchmark) for the
|
|
52
|
+
operator matrix, measured costs, SQL, and evidence limits.
|
|
53
|
+
|
|
54
|
+
### Optional planner statistics for correlated field/value predicates
|
|
55
|
+
|
|
56
|
+
TypedEAV does not install PostgreSQL extended-statistics objects. An application
|
|
57
|
+
whose own plans persistently misestimate `field_id = ... AND typed_value = ...`
|
|
58
|
+
may evaluate application-owned `dependencies` statistics for that exact typed
|
|
59
|
+
column. Dependency statistics apply to compatible equality and `IN` clauses,
|
|
60
|
+
not range predicates. `mcv` describes common value combinations, while
|
|
61
|
+
`ndistinct` primarily informs distinct-group estimates; neither should be added
|
|
62
|
+
without workload evidence.
|
|
63
|
+
|
|
64
|
+
The representative PostgreSQL 17 benchmark found better aggregate equality
|
|
65
|
+
estimates from dependencies, but no plan-shape or demonstrated runtime benefit.
|
|
66
|
+
Its combined object mirrored MCV on the four changed probes because matching MCV
|
|
67
|
+
groups supplied those estimates. The experiment's target of 100 was a controlled
|
|
68
|
+
input, not a universal recommendation. One probe labeled common-date equality
|
|
69
|
+
actually queried an absent date and returned zero rows; it is not evidence about
|
|
70
|
+
common-date estimates.
|
|
71
|
+
|
|
72
|
+
Applications should own stable names and DDL, select targets from representative
|
|
73
|
+
data, run `ANALYZE`, and compare estimated/actual rows, plans, runtime, planning
|
|
74
|
+
cost, maintenance cost, and data churn before retaining an object. Coordinate
|
|
75
|
+
ownership in shared databases, inspect catalog definitions before changing
|
|
76
|
+
objects, and drop only application-owned statistics during rollback. See
|
|
77
|
+
[ADR 0010](../adr/0010-planner-statistics-policy.md) and the
|
|
78
|
+
[benchmark guide](https://github.com/dchuk/typed_eav/blob/main/bench/README.md#phase-4a-planner-extended-statistics) for safe
|
|
79
|
+
evaluation SQL and evidence limits.
|
|
80
|
+
|
|
81
|
+
### Multi-filter query strategy
|
|
82
|
+
|
|
83
|
+
TypedEAV retains its current multi-filter query shape: it resolves each field,
|
|
84
|
+
builds the corresponding typed value subquery, and chains those results onto
|
|
85
|
+
the host relation with `id IN (...)`. There is no adaptive strategy or alternate
|
|
86
|
+
production query API.
|
|
87
|
+
|
|
88
|
+
A PostgreSQL 17 benchmark compared the shipped shape with `INTERSECT`,
|
|
89
|
+
correlated `EXISTS`, and direct grouped `HAVING` under resource-capped
|
|
90
|
+
co-tenancy. The run retained 2,940 attempts, including 622 right-censored
|
|
91
|
+
timeouts, and 294 representative identity oracles. Twelve oracles timed out, so
|
|
92
|
+
representative equivalence is unproved even though all 282 completed oracles
|
|
93
|
+
matched and the smaller 98-oracle smoke matched. Alternatives remain
|
|
94
|
+
research-only. Grouped `HAVING` is additionally ineligible for missing-value,
|
|
95
|
+
host-universe complement, and empty-filter semantics.
|
|
96
|
+
|
|
97
|
+
The result also does not establish valid buffer comparisons or 20-distinct-
|
|
98
|
+
field scaling. A parser defect made every derived buffer total a false zero;
|
|
99
|
+
nonzero counters remain recoverable from the retained raw plans. The
|
|
100
|
+
20-predicate workloads repeat ten fields, and the skewed 10/20 workloads repeat
|
|
101
|
+
five. Future research must repair and validate buffer extraction, exercise
|
|
102
|
+
actual 10/20 distinct fields, complete every representative equivalence oracle,
|
|
103
|
+
cover the full scope/NULL/missing/polymorphic/error contract, and show the
|
|
104
|
+
pre-registered p95, planning-time, buffer, and plan-shape gates before any
|
|
105
|
+
adaptive or replacement proposal. See
|
|
106
|
+
[ADR 0011](../adr/0011-multi-filter-query-strategy.md) and the
|
|
107
|
+
[benchmark guide](https://github.com/dchuk/typed_eav/blob/main/bench/README.md#phase-4b-multi-filter-query-shapes).
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Querying typed fields"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Querying typed fields
|
|
6
|
+
|
|
7
|
+
[Documentation home](../index.md)
|
|
8
|
+
|
|
9
|
+
## Filtering
|
|
10
|
+
|
|
11
|
+
Queries use native typed columns and the indexes shipped for each type. The Field remains the owner of operand casting and validation; the query builder receives a field-normalized value rather than applying a generic Active Record cast.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Short form - single field filter
|
|
15
|
+
Contact.with_field("age", :gt, 21)
|
|
16
|
+
Contact.with_field("status", "active") # :eq is the default operator
|
|
17
|
+
Contact.with_field("nickname", :contains, "smith")
|
|
18
|
+
|
|
19
|
+
# Chain them
|
|
20
|
+
Contact.with_field("age", :gteq, 18)
|
|
21
|
+
.with_field("status", "active")
|
|
22
|
+
.with_field("tags", :any_eq, "vip")
|
|
23
|
+
|
|
24
|
+
# Multi-filter form (good for search UIs)
|
|
25
|
+
Contact.where_typed_eav(
|
|
26
|
+
{ name: "age", op: :gt, value: 21 },
|
|
27
|
+
{ name: "status", op: :eq, value: "active" },
|
|
28
|
+
{ name: "city", op: :contains, value: "port" },
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Compact keys (for URL params / form submissions)
|
|
32
|
+
Contact.where_typed_eav(
|
|
33
|
+
{ n: "age", op: :gt, v: 21 },
|
|
34
|
+
{ n: "status", v: "active" },
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# With scoping
|
|
38
|
+
Contact.where_typed_eav(
|
|
39
|
+
{ name: "priority", op: :eq, value: "high" },
|
|
40
|
+
scope: current_tenant.id
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Combine with standard ActiveRecord
|
|
44
|
+
Contact.where(company_id: 42)
|
|
45
|
+
.with_field("status", "active")
|
|
46
|
+
.with_field("age", :gteq, 21)
|
|
47
|
+
.order(:name)
|
|
48
|
+
.limit(25)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Missing values and explicit NULL
|
|
52
|
+
|
|
53
|
+
By default, `:is_null` finds existing Value rows whose typed value is NULL.
|
|
54
|
+
It does not find hosts with no Value row. For an "is empty" search that covers
|
|
55
|
+
both cases, opt into `include_missing:`:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
contacts = Contact.where(tenant_id: "t1")
|
|
59
|
+
contacts.with_field("age", :is_null, scope: "t1")
|
|
60
|
+
contacts.with_field("age", :is_null, scope: "t1", include_missing: true)
|
|
61
|
+
|
|
62
|
+
# The same option applies to every :is_null filter in a multi-filter query:
|
|
63
|
+
contacts.where_typed_eav(
|
|
64
|
+
{ name: "age", op: :is_null },
|
|
65
|
+
{ name: "status", value: "active" },
|
|
66
|
+
scope: "t1", include_missing: true
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`include_missing: true` means the host has **no non-NULL value** for the
|
|
71
|
+
selected definition. It has no effect on `:is_not_null` or any other operator;
|
|
72
|
+
it does not make an unknown field name valid. In administrative
|
|
73
|
+
`TypedEAV.unscoped` queries, no matching same-name definition may hold a
|
|
74
|
+
non-NULL value for that host. Other filters remain ANDed, and host authorization
|
|
75
|
+
still belongs on the caller relation.
|
|
76
|
+
|
|
77
|
+
## Sorting by a typed field
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
Contact.where(tenant_id: "t1")
|
|
81
|
+
.order_typed_eav("age", direction: :desc, nulls: :last, scope: "t1")
|
|
82
|
+
.limit(25)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`order_typed_eav` returns an Active Record relation and orders in PostgreSQL,
|
|
86
|
+
without loading typed values into Ruby. It replaces prior ordering while
|
|
87
|
+
preserving host filters, STI restrictions, limits, and offsets. `direction:`
|
|
88
|
+
accepts `:asc` (default) or `:desc`; `nulls:` accepts `:first` or `:last`
|
|
89
|
+
(default in either direction). Missing rows and explicit NULLs share that
|
|
90
|
+
placement. Equal values use the host primary key ascending as a stable tie-break.
|
|
91
|
+
|
|
92
|
+
Scope arguments (or the ambient scope) select the winning field definition;
|
|
93
|
+
**they do not filter host records by tenant**. Keep authorization/tenant filters
|
|
94
|
+
on the caller relation. All-partitions `TypedEAV.unscoped` is rejected for this
|
|
95
|
+
API: choose one effective definition instead. Single native scalar cells are
|
|
96
|
+
supported, using their stored values (for example, reference IDs and attachment
|
|
97
|
+
signed IDs, not display labels). JSON/array and multi-cell fields such as
|
|
98
|
+
Currency are rejected rather than assigned an implicit ordering.
|
|
99
|
+
|
|
100
|
+
## Distinct values and grouped counts
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
contacts = Contact.where(tenant_id: "t1")
|
|
104
|
+
contacts.distinct_typed_eav_values("status", scope: "t1", limit: 100)
|
|
105
|
+
# => ["active", "paused", nil]
|
|
106
|
+
contacts.typed_eav_value_counts("status", scope: "t1", limit: 100)
|
|
107
|
+
# => {"active" => 24, "paused" => 3, nil => 2}
|
|
108
|
+
contacts.count_distinct_typed_eav_values("status", scope: "t1")
|
|
109
|
+
# => 3
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
These scalar queries run in SQL without hydrating hosts or Values. Results
|
|
113
|
+
use native ascending value order, with explicit NULL (`nil`) last. Missing
|
|
114
|
+
value rows contribute nothing; `false` and empty strings remain real values.
|
|
115
|
+
Grouped counts count host identities, not duplicate rows introduced by joins.
|
|
116
|
+
The caller's filters, STI restrictions, distinctness, and pagination determine
|
|
117
|
+
the host set before summarization. Scope arguments choose field-definition
|
|
118
|
+
visibility, not host authorization, just as with typed sorting.
|
|
119
|
+
|
|
120
|
+
Lists and grouped-count hashes default to 100 values and accept a positive
|
|
121
|
+
Integer `limit:` up to 1,000. They are truncated in value order, not ranked by
|
|
122
|
+
frequency. Compare their length with `count_distinct_typed_eav_values` to
|
|
123
|
+
detect truncation; that exact SQL count includes one NULL category and returns
|
|
124
|
+
only an Integer, regardless of cardinality. It still requires database work
|
|
125
|
+
over the matching set. Collection/multi-cell fields and all-partitions mode
|
|
126
|
+
are unsupported, matching scalar sorting.
|
|
127
|
+
|
|
128
|
+
## Numeric aggregates
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
Contact.where(tenant_id: "t1").aggregate_typed_eav(
|
|
132
|
+
"score", operation: :sum, scope: "t1"
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`aggregate_typed_eav` requires `operation: :min`, `:max`, or `:sum` and returns
|
|
137
|
+
one SQL-calculated scalar over the caller's host set. Integer fields return
|
|
138
|
+
Integers; Decimal and Percentage fields preserve `BigDecimal` precision, with
|
|
139
|
+
no Float conversion. Percentage values remain stored fractions, not formatted
|
|
140
|
+
percent strings. Missing rows and explicit NULLs are ignored. With no non-NULL
|
|
141
|
+
values, min/max return `nil` and sum returns the field's typed zero.
|
|
142
|
+
|
|
143
|
+
The same host filtering, partition visibility, STI, and pagination rules as
|
|
144
|
+
distinct queries apply. Only Integer/Decimal families (including Percentage)
|
|
145
|
+
with a single numeric cell are supported. Reference IDs, text, collections,
|
|
146
|
+
and multi-cell Currency are rejected; the gem does not silently sum identifiers
|
|
147
|
+
or combine currencies. No values or host records are loaded to compute the result.
|
|
148
|
+
|
|
149
|
+
## Available Operators
|
|
150
|
+
|
|
151
|
+
| Operator | Works On | Description |
|
|
152
|
+
|----------|----------|-------------|
|
|
153
|
+
| `:eq` | all | Equal (default) |
|
|
154
|
+
| `:not_eq` | all | Not equal (NULL-safe) |
|
|
155
|
+
| `:gt` | numeric, date, datetime | Greater than |
|
|
156
|
+
| `:gteq` | numeric, date, datetime | Greater than or equal |
|
|
157
|
+
| `:lt` | numeric, date, datetime | Less than |
|
|
158
|
+
| `:lteq` | numeric, date, datetime | Less than or equal |
|
|
159
|
+
| `:between` | numeric, date, datetime | Between (pass Range or Array) |
|
|
160
|
+
| `:contains` | text, long_text | ILIKE %value% |
|
|
161
|
+
| `:not_contains` | text, long_text | NOT ILIKE %value% |
|
|
162
|
+
| `:starts_with` | text, long_text | ILIKE value% |
|
|
163
|
+
| `:ends_with` | text, long_text | ILIKE %value |
|
|
164
|
+
| `:any_eq` | json arrays | Array contains element |
|
|
165
|
+
| `:all_eq` | json arrays | Array contains all elements |
|
|
166
|
+
| `:is_null` | all | Value is NULL |
|
|
167
|
+
| `:is_not_null` | all | Value is not NULL |
|
|
168
|
+
|
|
169
|
+
## How Type Inference Works
|
|
170
|
+
|
|
171
|
+
The owning Field casts and validates query operands before SQL generation;
|
|
172
|
+
Active Record supplies the SQL bind plumbing:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
# The Integer Field casts and validates the operand before SQL generation
|
|
176
|
+
Contact.with_field("age", :gt, "21")
|
|
177
|
+
# SQL: WHERE integer_value > 21 (not '21')
|
|
178
|
+
|
|
179
|
+
# The Date Field owns date parsing and validation
|
|
180
|
+
Contact.with_field("birthday", :lt, "2000-01-01")
|
|
181
|
+
# SQL: WHERE date_value < '2000-01-01'::date
|
|
182
|
+
|
|
183
|
+
# The Boolean Field owns truthy/falsy casting
|
|
184
|
+
Contact.with_field("active", "true")
|
|
185
|
+
# SQL: WHERE boolean_value = TRUE
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Field-owned casting keeps query operands aligned with write semantics, including strict range/array shapes and specialized fields such as Currency and Reference. The resulting normalized operand is bound against the Field's typed column; Active Record supplies SQL bind plumbing, not the field's domain semantics.
|