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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -2
  3. data/README.md +79 -1710
  4. data/RELEASING.md +81 -0
  5. data/docs/adr/0001-collapse-column-mapping-stack.md +28 -0
  6. data/docs/adr/0002-entity-query-orchestration.md +33 -0
  7. data/docs/adr/0003-keep-event-dispatcher-broker.md +41 -0
  8. data/docs/adr/0004-field-family-intermediate-bases.md +49 -0
  9. data/docs/adr/0005-keep-phase-six-modules-independent.md +48 -0
  10. data/docs/adr/0006-include-missing-via-set-complement.md +77 -0
  11. data/docs/adr/0007-visibility-versus-mutation-relations.md +33 -0
  12. data/docs/adr/0008-partial-covering-scalar-indexes.md +83 -0
  13. data/docs/adr/0009-string-search-indexing.md +110 -0
  14. data/docs/adr/0010-planner-statistics-policy.md +109 -0
  15. data/docs/adr/0011-multi-filter-query-strategy.md +111 -0
  16. data/docs/adr/0012-cross-scope-administrative-query-policy.md +76 -0
  17. data/docs/adr/0013-durable-versioning-and-field-deletion.md +125 -0
  18. data/docs/adr/index.md +101 -0
  19. data/docs/getting-started.md +79 -0
  20. data/docs/guides/architecture.md +125 -0
  21. data/docs/guides/bulk-operations.md +205 -0
  22. data/docs/guides/csv-import.md +88 -0
  23. data/docs/guides/development.md +73 -0
  24. data/docs/guides/events-and-versioning.md +360 -0
  25. data/docs/guides/fields.md +342 -0
  26. data/docs/guides/performance.md +107 -0
  27. data/docs/guides/queries.md +188 -0
  28. data/docs/guides/schema.md +134 -0
  29. data/docs/guides/scoping.md +254 -0
  30. data/docs/guides/upgrading.md +26 -0
  31. data/docs/guides/usage.md +259 -0
  32. data/docs/index.md +44 -0
  33. data/docs/maintaining.md +82 -0
  34. data/docs/reference/api.md +133 -0
  35. data/docs/reference/configuration.md +64 -0
  36. data/docs/reference/index.md +16 -0
  37. data/lib/typed_eav/version.rb +1 -1
  38. 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.