typed_eav 0.1.0 → 0.2.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 +89 -0
- data/README.md +634 -2
- data/app/models/typed_eav/field/base.rb +552 -6
- data/app/models/typed_eav/field/currency.rb +125 -0
- data/app/models/typed_eav/field/file.rb +98 -0
- data/app/models/typed_eav/field/image.rb +152 -0
- data/app/models/typed_eav/field/percentage.rb +100 -0
- data/app/models/typed_eav/field/reference.rb +230 -0
- data/app/models/typed_eav/section.rb +114 -4
- data/app/models/typed_eav/value.rb +461 -11
- data/app/models/typed_eav/value_version.rb +96 -0
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
- data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
- data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
- data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
- data/lib/typed_eav/bulk_write.rb +147 -0
- data/lib/typed_eav/column_mapping.rb +46 -0
- data/lib/typed_eav/config.rb +215 -19
- data/lib/typed_eav/csv_mapper.rb +158 -0
- data/lib/typed_eav/currency_storage_contract.rb +46 -0
- data/lib/typed_eav/engine.rb +117 -0
- data/lib/typed_eav/event_dispatcher.rb +151 -0
- data/lib/typed_eav/field_storage_contract.rb +68 -0
- data/lib/typed_eav/has_typed_eav.rb +455 -58
- data/lib/typed_eav/partition.rb +64 -0
- data/lib/typed_eav/query_builder.rb +39 -3
- data/lib/typed_eav/registry.rb +48 -9
- data/lib/typed_eav/schema_portability.rb +250 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +73 -0
- data/lib/typed_eav/versioning/subscriber.rb +161 -0
- data/lib/typed_eav/versioning.rb +94 -0
- data/lib/typed_eav.rb +180 -12
- metadata +36 -2
data/README.md
CHANGED
|
@@ -333,7 +333,12 @@ TypedEAV.configure do |c|
|
|
|
333
333
|
end
|
|
334
334
|
```
|
|
335
335
|
|
|
336
|
-
The resolver
|
|
336
|
+
The resolver MUST return a 2-element Array `[scope, parent_scope]`. Each slot
|
|
337
|
+
accepts a raw value (`"t1"`, `42`), an AR record (TypedEAV calls `.id.to_s`
|
|
338
|
+
on anything that responds to `#id`), or `nil`. If you don't use parent_scope,
|
|
339
|
+
return `[scope, nil]`. A bare scalar return raises `ArgumentError` at the
|
|
340
|
+
next ambient query — see [Migrating from v0.1.x](#migrating-from-v01x) for
|
|
341
|
+
the upgrade path.
|
|
337
342
|
|
|
338
343
|
### Block APIs
|
|
339
344
|
|
|
@@ -385,6 +390,87 @@ If your app has existing typed-eav queries that don't yet pass scope, flip `requ
|
|
|
385
390
|
|
|
386
391
|
To intentionally query across every partition (admin tools, migrations, cross-tenant audits), use the explicit escape hatch `TypedEAV.unscoped { ... }` rather than relying on `require_scope = false`.
|
|
387
392
|
|
|
393
|
+
### Two-level scoping (`parent_scope`)
|
|
394
|
+
|
|
395
|
+
When a single tenant axis isn't enough — say, `tenant_id` for the customer AND
|
|
396
|
+
`workspace_id` for an in-tenant partition — declare both:
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
class Project < ApplicationRecord
|
|
400
|
+
has_typed_eav scope_method: :tenant_id, parent_scope_method: :workspace_id
|
|
401
|
+
end
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Field (and section) definitions partition on the tuple `(entity_type, scope,
|
|
405
|
+
parent_scope)`. A `Project` record reads field definitions in three precedence
|
|
406
|
+
layers: a full-triple `(scope, parent_scope)` match wins, then `(scope, nil)`
|
|
407
|
+
(tenant-wide), then `(nil, nil)` (truly global). The same precedence applies
|
|
408
|
+
to the class-level query path.
|
|
409
|
+
|
|
410
|
+
`parent_scope_method:` requires `scope_method:` — declaring it without a scope
|
|
411
|
+
method raises at macro-expansion time (no host can have a parent partition
|
|
412
|
+
without a scope partition).
|
|
413
|
+
|
|
414
|
+
Both `with_scope` and the configured `scope_resolver` carry the tuple now:
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
TypedEAV.with_scope(["t1", "w1"]) do
|
|
418
|
+
Project.where_typed_eav({ name: "status", value: "active" })
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Single-axis call still works (parent_scope = nil):
|
|
422
|
+
TypedEAV.with_scope("t1") do
|
|
423
|
+
Contact.where_typed_eav({ name: "age", op: :gt, value: 21 })
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Custom resolver — MUST return [scope, parent_scope]:
|
|
427
|
+
TypedEAV.configure do |c|
|
|
428
|
+
c.scope_resolver = -> { [Current.tenant&.id, Current.workspace&.id] }
|
|
429
|
+
end
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Per-query overrides accept `parent_scope:` alongside `scope:` on
|
|
433
|
+
`where_typed_eav`, `with_field`, and `typed_eav_definitions`:
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
Project.where_typed_eav(
|
|
437
|
+
{ name: "priority", value: "high" },
|
|
438
|
+
scope: "t1",
|
|
439
|
+
parent_scope: "w1",
|
|
440
|
+
)
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
When `acts_as_tenant` is loaded, the auto-detected `DEFAULT_SCOPE_RESOLVER`
|
|
444
|
+
returns `[ActsAsTenant.current_tenant, nil]` — the parent_scope slot is `nil`
|
|
445
|
+
because the tenant gem has no parent-scope analog. Configure your own resolver
|
|
446
|
+
when you need both axes.
|
|
447
|
+
|
|
448
|
+
### Migrating from v0.1.x
|
|
449
|
+
|
|
450
|
+
The resolver-callable contract is a **breaking change**: any custom
|
|
451
|
+
`Config.scope_resolver` lambda must now return `[scope, parent_scope]` (a
|
|
452
|
+
2-element Array) instead of a bare scalar. A scalar return raises
|
|
453
|
+
`ArgumentError` at the next ambient query so the failure is loud, not silent.
|
|
454
|
+
If you don't use parent_scope, return `[scope, nil]`.
|
|
455
|
+
|
|
456
|
+
Run `bin/rails typed_eav:install:migrations` to copy the new
|
|
457
|
+
`AddParentScopeToTypedEavPartitions` migration into your app, then
|
|
458
|
+
`bin/rails db:migrate`. The migration is safe on production: it adds a
|
|
459
|
+
nullable `parent_scope` column (catalog-only, instantaneous) and uses
|
|
460
|
+
`CREATE INDEX CONCURRENTLY` for all index changes, so existing rows aren't
|
|
461
|
+
rewritten. Existing fields end up with `parent_scope = NULL` (the
|
|
462
|
+
global-parent shape) and continue to work for every single-scope caller.
|
|
463
|
+
|
|
464
|
+
See the [CHANGELOG](CHANGELOG.md) for the full upgrade checklist.
|
|
465
|
+
|
|
466
|
+
### Orphan-parent invariant
|
|
467
|
+
|
|
468
|
+
A `Field` or `Section` row with `parent_scope` set and `scope` blank is
|
|
469
|
+
invalid — model-level validation rejects it on save. Reason: a "global field
|
|
470
|
+
within one workspace" has no semantic resolution path; the row would never
|
|
471
|
+
match any record's resolver. The paired partial unique indexes rely on this
|
|
472
|
+
invariant.
|
|
473
|
+
|
|
388
474
|
### Name collisions across scopes
|
|
389
475
|
|
|
390
476
|
When both a global field (`scope: nil`) and a scoped field share a name, the **scoped definition wins** for the partition that owns it: forms render exactly one input (the scoped one), reads return the scoped value, and writes target the scoped row.
|
|
@@ -412,6 +498,11 @@ When both a global field (`scope: nil`) and a scoped field share a name, the **s
|
|
|
412
498
|
| `Url` | `string_value` | String | strips whitespace |
|
|
413
499
|
| `Color` | `string_value` | String | hex color values |
|
|
414
500
|
| `Json` | `json_value` | Hash/Array | arbitrary JSON |
|
|
501
|
+
| `Currency` | `decimal_value` + `string_value` | `{amount: BigDecimal, currency: String}` | `default_currency`, `allowed_currencies` |
|
|
502
|
+
| `Percentage` | `decimal_value` | BigDecimal (0..1 range) | `decimal_places`, `display_as: :fraction \| :percent` |
|
|
503
|
+
| `Image` | `string_value` (signed_id) + `:attachment` has_one_attached | String (Active Storage signed_id) | `allowed_content_types`, `max_size_bytes` |
|
|
504
|
+
| `File` | `string_value` (signed_id) + `:attachment` has_one_attached | String (Active Storage signed_id) | `allowed_content_types`, `max_size_bytes` |
|
|
505
|
+
| `Reference` | `integer_value` (FK) | Integer (target record ID) | `target_entity_type`, `target_scope` |
|
|
415
506
|
|
|
416
507
|
## Sections (Optional UI Grouping)
|
|
417
508
|
|
|
@@ -464,6 +555,129 @@ TypedEAV.configure do |c|
|
|
|
464
555
|
end
|
|
465
556
|
```
|
|
466
557
|
|
|
558
|
+
### Multi-cell field types
|
|
559
|
+
|
|
560
|
+
External field types may store their logical value across multiple typed
|
|
561
|
+
columns. Storage behavior is exposed through `field.storage_contract`,
|
|
562
|
+
which keeps callers from knowing whether a field is single-cell or
|
|
563
|
+
multi-cell. The contract covers:
|
|
564
|
+
|
|
565
|
+
- `value_columns` - the native typed cells used for storage, snapshots,
|
|
566
|
+
reverting, and update change detection.
|
|
567
|
+
- `read(value_record)` / `write(value_record, casted)` - logical value
|
|
568
|
+
reads and writes.
|
|
569
|
+
- `apply_default(value_record)` - default application across the field's
|
|
570
|
+
storage cells.
|
|
571
|
+
- `query_column(operator)` - query routing for each supported operator.
|
|
572
|
+
- `before_snapshot(value_record, change_type)` /
|
|
573
|
+
`after_snapshot(value_record, change_type)` - version row jsonb shape.
|
|
574
|
+
- `changed?(value_record)` - update event gating across every storage
|
|
575
|
+
cell the field owns.
|
|
576
|
+
|
|
577
|
+
Single-cell field types inherit the default contract. For multi-cell
|
|
578
|
+
fields, create a `TypedEAV::FieldStorageContract` subclass and select it
|
|
579
|
+
from the field class:
|
|
580
|
+
|
|
581
|
+
```ruby
|
|
582
|
+
class MoneyStorageContract < TypedEAV::FieldStorageContract
|
|
583
|
+
def self.value_columns = %i[decimal_value string_value]
|
|
584
|
+
def self.query_column(operator) = operator == :currency_eq ? :string_value : :decimal_value
|
|
585
|
+
|
|
586
|
+
def read(value_record)
|
|
587
|
+
amount = value_record[:decimal_value]
|
|
588
|
+
currency = value_record[:string_value]
|
|
589
|
+
amount.nil? && currency.nil? ? nil : { amount: amount, currency: currency }
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def write(value_record, casted)
|
|
593
|
+
value_record[:decimal_value] = casted&.fetch(:amount, nil)
|
|
594
|
+
value_record[:string_value] = casted&.fetch(:currency, nil)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def apply_default(value_record)
|
|
598
|
+
default = field.default_value
|
|
599
|
+
return unless default.is_a?(Hash)
|
|
600
|
+
|
|
601
|
+
value_record[:decimal_value] = default[:amount] || default["amount"]
|
|
602
|
+
value_record[:string_value] = default[:currency] || default["currency"]
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
class Fields::Money < TypedEAV::Field::Base
|
|
607
|
+
value_column :decimal_value
|
|
608
|
+
storage_contract_class MoneyStorageContract
|
|
609
|
+
end
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Compatibility helpers such as `self.value_columns` and
|
|
613
|
+
`self.operator_column(operator)` may delegate to the selected contract
|
|
614
|
+
when older callers still use those class methods.
|
|
615
|
+
|
|
616
|
+
Defaults delegate to `value_column` for single-cell storage, so existing
|
|
617
|
+
single-cell types are unchanged. The built-in `Field::Currency` is the
|
|
618
|
+
canonical multi-cell consumer of these extension points.
|
|
619
|
+
|
|
620
|
+
### Built-in field types
|
|
621
|
+
|
|
622
|
+
- **`Currency`:** Stores `{amount: BigDecimal, currency: String}` across two typed columns (`decimal_value` for the amount; `string_value` for the ISO 4217 currency code) through `CurrencyStorageContract`. Operators: `:eq`, `:gt`, `:lt`, `:gteq`, `:lteq`, `:between` target the amount; `:currency_eq` targets the currency code; `:is_null` / `:is_not_null` target the amount column (a Currency value is null when its amount is null). Cast input MUST be a hash with `:amount` and/or `:currency` keys — bare numeric/string values are rejected with `:invalid` to enforce explicit currency dimension at write time. Options: `default_currency` (String ISO code, applied as fallback only when an amount is given without an explicit currency), `allowed_currencies` (Array of ISO codes; `validate_typed_value` enforces inclusion). Versioning snapshots automatically capture both columns through the storage contract. The `:currency_eq` operator is registered ONLY on `Field::Currency`; the QueryBuilder operator-validation gate rejects it with a clear `ArgumentError` if invoked on any other field type.
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
Contact.where_typed_eav(name: "price", op: :currency_eq, value: "USD")
|
|
626
|
+
Contact.where_typed_eav(name: "price", op: :between, value: [50, 150])
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
- **`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`.
|
|
630
|
+
|
|
631
|
+
```ruby
|
|
632
|
+
pf = TypedEAV::Field::Percentage.create!(
|
|
633
|
+
name: "discount", entity_type: "Order", scope: tenant_id,
|
|
634
|
+
options: { display_as: :percent, decimal_places: 1 },
|
|
635
|
+
)
|
|
636
|
+
pf.format(BigDecimal("0.755")) # => "75.5%"
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
- **`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.
|
|
640
|
+
|
|
641
|
+
```ruby
|
|
642
|
+
field = TypedEAV::Field::Image.create!(
|
|
643
|
+
name: "avatar", entity_type: "Contact",
|
|
644
|
+
options: { allowed_content_types: %w[image/png image/jpeg image/webp], max_size_bytes: 5_000_000 },
|
|
645
|
+
)
|
|
646
|
+
value = TypedEAV::Value.create!(entity: contact, field: field)
|
|
647
|
+
value.attachment.attach(io: file_io, filename: "avatar.png", content_type: "image/png")
|
|
648
|
+
value.update!(string_value: value.attachment.blob.signed_id)
|
|
649
|
+
value.value # => the signed_id String
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
- **`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.
|
|
653
|
+
|
|
654
|
+
- **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 (already included in Rails 7.1+ 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`).
|
|
655
|
+
|
|
656
|
+
- **`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.
|
|
657
|
+
|
|
658
|
+
```ruby
|
|
659
|
+
TypedEAV.configure do |c|
|
|
660
|
+
c.on_image_attached = ->(value, blob) {
|
|
661
|
+
ProcessImageJob.perform_later(value.id, blob.id)
|
|
662
|
+
}
|
|
663
|
+
end
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
- **`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.
|
|
667
|
+
|
|
668
|
+
```ruby
|
|
669
|
+
rf = TypedEAV::Field::Reference.create!(
|
|
670
|
+
name: "manager", entity_type: "Contact", scope: tenant_id,
|
|
671
|
+
options: { target_entity_type: "Contact", target_scope: tenant_id },
|
|
672
|
+
)
|
|
673
|
+
TypedEAV::Value.create!(entity: alice, field: rf, value: bob) # accepts AR record
|
|
674
|
+
TypedEAV::Value.create!(entity: alice, field: rf, value: bob.id) # accepts Integer FK
|
|
675
|
+
Contact.where_typed_eav(name: "manager", op: :references, value: bob) # filter by record
|
|
676
|
+
Contact.where_typed_eav(name: "manager", op: :references, value: 42) # filter by FK
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
- **Summary:** The built-in field types **Image, File, Reference, Currency, Percentage** all preserve the cast-tuple contract (`[casted, invalid?]`), the operator-dispatch model (`supported_operators` + `operator_column` for multi-cell types), and the no-hardcoded-attribute-references foundational principle. The multi-cell extension surface (`read_value`, `apply_default_to`, `operator_column`, and `write_value`) is the canonical way to build any future external multi-cell field type.
|
|
680
|
+
|
|
467
681
|
## Validation Behavior
|
|
468
682
|
|
|
469
683
|
A few non-obvious contracts worth knowing about up front:
|
|
@@ -474,12 +688,430 @@ A few non-obvious contracts worth knowing about up front:
|
|
|
474
688
|
- **`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.
|
|
475
689
|
- **`TextArray` does not support `:contains`**: it backs a jsonb column where SQL `LIKE` doesn't apply. Use `:any_eq` for "array contains element".
|
|
476
690
|
- **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.
|
|
477
|
-
- **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`.
|
|
691
|
+
- **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.
|
|
692
|
+
- **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.
|
|
693
|
+
- **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" for the full contract.
|
|
694
|
+
- **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" for the full contract.
|
|
695
|
+
|
|
696
|
+
## Event hooks
|
|
697
|
+
|
|
698
|
+
`typed_eav` fires `after_commit` events for value and field changes. Use them
|
|
699
|
+
for audit logs, search-index synchronization, cache invalidation, or any
|
|
700
|
+
out-of-band reaction that must wait until the database write is durable.
|
|
701
|
+
|
|
702
|
+
### Public callback slots
|
|
703
|
+
|
|
704
|
+
```ruby
|
|
705
|
+
TypedEAV.configure do |c|
|
|
706
|
+
c.on_value_change = ->(value, change_type, context) {
|
|
707
|
+
# change_type ∈ [:create, :update, :destroy]
|
|
708
|
+
# context is a frozen Hash (see `with_context` below) — read-only
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
c.on_field_change = ->(field, change_type) {
|
|
712
|
+
# change_type ∈ [:create, :update, :destroy, :rename]
|
|
713
|
+
# NOTE: no context arg — field changes are CRUD-on-config, not
|
|
714
|
+
# per-entity user actions
|
|
715
|
+
}
|
|
716
|
+
end
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
The `:rename` change_type fires whenever the field's `name` column changed
|
|
720
|
+
in the just-committed save, even when bundled with other attribute changes
|
|
721
|
+
(options, sort_order, default_value, etc.). The detection is intentionally
|
|
722
|
+
escalating — Phase 7's materialized index needs to regenerate column DDL on
|
|
723
|
+
every rename.
|
|
724
|
+
|
|
725
|
+
`:update` on Value fires only when the typed value column changed. Saving
|
|
726
|
+
a Value record without modifying its typed column (e.g., touching only
|
|
727
|
+
bookkeeping columns) is a no-op for event dispatch.
|
|
728
|
+
|
|
729
|
+
`field_dependent: :nullify` cascades produce **no** Value `:destroy`
|
|
730
|
+
events. The FK `ON DELETE SET NULL` runs at the database level and
|
|
731
|
+
bypasses AR callbacks. Only the Field `:destroy` event fires. Use
|
|
732
|
+
`field_dependent: :destroy` if your consumer needs per-Value events on
|
|
733
|
+
field deletion.
|
|
734
|
+
|
|
735
|
+
### Thread-local context with `with_context`
|
|
736
|
+
|
|
737
|
+
```ruby
|
|
738
|
+
TypedEAV.with_context(request_id: request.uuid, actor_id: current_user.id) do
|
|
739
|
+
contact.update!(typed_eav: { phone: "555-1234" })
|
|
740
|
+
# on_value_change receives { request_id: "...", actor_id: 42 } as context
|
|
741
|
+
end
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
`with_context` is a thread-local stack with shallow per-key merge:
|
|
745
|
+
|
|
746
|
+
```ruby
|
|
747
|
+
TypedEAV.with_context(request_id: "abc") do
|
|
748
|
+
TypedEAV.with_context(source: :bulk) do
|
|
749
|
+
# current context: { request_id: "abc", source: :bulk }
|
|
750
|
+
end
|
|
751
|
+
# current context: { request_id: "abc" }
|
|
752
|
+
end
|
|
753
|
+
# current context: {}
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
The current-context hash is frozen — callbacks cannot mutate it. Outer
|
|
757
|
+
context is restored on exit even if the inner block raises.
|
|
758
|
+
|
|
759
|
+
`TypedEAV.current_context` returns the current frozen Hash (or a shared
|
|
760
|
+
frozen `{}` when no `with_context` block is active). It's safe to call
|
|
761
|
+
from any code path; it never returns nil.
|
|
762
|
+
|
|
763
|
+
### Error policy
|
|
764
|
+
|
|
765
|
+
User callbacks (`Config.on_value_change`, `Config.on_field_change`) are
|
|
766
|
+
rescued — exceptions are logged via `Rails.logger.error` and **do not
|
|
767
|
+
propagate** to the user's save call. The save row is already committed
|
|
768
|
+
when `after_commit` fires; re-raising would surface a misleading
|
|
769
|
+
"save failed" error.
|
|
770
|
+
|
|
771
|
+
This is the deliberate split with first-party features. Internal
|
|
772
|
+
subscribers used by `typed_eav` itself (Phase 4 versioning, Phase 7
|
|
773
|
+
materialized index) follow a different rule: their exceptions
|
|
774
|
+
**propagate**. Versioning corruption must be loud.
|
|
775
|
+
|
|
776
|
+
### Ordering guarantee
|
|
777
|
+
|
|
778
|
+
When multiple subscribers are registered, they fire in this order:
|
|
779
|
+
|
|
780
|
+
1. First-party internal subscribers (versioning, matview, etc.), in
|
|
781
|
+
registration order. Errors propagate.
|
|
782
|
+
2. The user proc on `Config.on_value_change` / `Config.on_field_change`,
|
|
783
|
+
last. Errors are rescued and logged.
|
|
784
|
+
|
|
785
|
+
Reassigning `Config.on_value_change` after gem initialization does **not**
|
|
786
|
+
disable internal subscribers — they live on a separate dispatcher list
|
|
787
|
+
and survive `Config.reset!`.
|
|
788
|
+
|
|
789
|
+
### Test isolation
|
|
790
|
+
|
|
791
|
+
Test files that exercise event hooks should opt in to the `:event_callbacks`
|
|
792
|
+
metadata:
|
|
793
|
+
|
|
794
|
+
```ruby
|
|
795
|
+
RSpec.describe "my feature", :event_callbacks do
|
|
796
|
+
it "fires the hook" do
|
|
797
|
+
captured = []
|
|
798
|
+
TypedEAV::Config.on_value_change = ->(v, t, _ctx) { captured << [v.id, t] }
|
|
799
|
+
contact.update!(typed_eav: { phone: "555-1234" })
|
|
800
|
+
expect(captured).to include([be_a(Integer), :update])
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshots and
|
|
806
|
+
restores Config user procs and the internal-subscriber lists around each
|
|
807
|
+
example, so test mutations don't leak across examples and engine-load
|
|
808
|
+
registrations from later phases stay intact.
|
|
809
|
+
|
|
810
|
+
Integration specs that create real AR records and need `after_commit` to
|
|
811
|
+
fire durably should additionally opt in to `:real_commits`:
|
|
812
|
+
|
|
813
|
+
```ruby
|
|
814
|
+
RSpec.describe "my model", :event_callbacks, :real_commits do
|
|
815
|
+
# ...
|
|
816
|
+
end
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
`:real_commits` disables transactional fixtures for the example and
|
|
820
|
+
manually deletes typed_eav rows in FK order after.
|
|
821
|
+
|
|
822
|
+
### Reset semantics
|
|
823
|
+
|
|
824
|
+
| Method | What it resets |
|
|
825
|
+
|---|---|
|
|
826
|
+
| `TypedEAV::Config.reset!` | User procs (`on_value_change`, `on_field_change`) plus `field_types`, `scope_resolver`, `require_scope`. Does **not** clear internal subscribers. |
|
|
827
|
+
| `TypedEAV::EventDispatcher.reset!` | Internal subscribers only. Does **not** touch Config. |
|
|
828
|
+
|
|
829
|
+
Production code rarely calls either — they exist for test isolation and
|
|
830
|
+
for the rare case where a host app wants to fully unwire the gem in a
|
|
831
|
+
specific request lifecycle.
|
|
832
|
+
|
|
833
|
+
## Versioning
|
|
834
|
+
|
|
835
|
+
`typed_eav` ships an opt-in append-only audit log for changes to typed
|
|
836
|
+
values. When enabled, each `:create` / `:update` / `:destroy` event on
|
|
837
|
+
a Value writes a row to `typed_eav_value_versions` capturing the
|
|
838
|
+
before-state, after-state, actor, context, and timestamp.
|
|
839
|
+
|
|
840
|
+
Default off. Apps that don't enable it pay zero overhead — the Phase 04
|
|
841
|
+
internal subscriber is not registered with `EventDispatcher.value_change_internals`
|
|
842
|
+
at all when `Config.versioning = false`. Zero callable in the dispatcher
|
|
843
|
+
chain, zero per-write method dispatch, zero per-write config read.
|
|
844
|
+
|
|
845
|
+
### Enabling versioning
|
|
846
|
+
|
|
847
|
+
Two steps:
|
|
848
|
+
|
|
849
|
+
```ruby
|
|
850
|
+
# 1. Set the gem-level master switch in an initializer.
|
|
851
|
+
# config/initializers/typed_eav.rb
|
|
852
|
+
TypedEAV.configure do |c|
|
|
853
|
+
c.versioning = true
|
|
854
|
+
c.actor_resolver = -> { Current.user } # optional; nil is permissive
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# 2. Opt the host model in. Either via the kwarg form:
|
|
858
|
+
class Contact < ApplicationRecord
|
|
859
|
+
has_typed_eav scope_method: :tenant_id, versioned: true
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
# Or via the concern (equivalent — pick whichever fits your conventions):
|
|
863
|
+
class Contact < ApplicationRecord
|
|
864
|
+
has_typed_eav scope_method: :tenant_id
|
|
865
|
+
include TypedEAV::Versioned
|
|
866
|
+
end
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
The two opt-in forms produce identical Registry state. The kwarg form is
|
|
870
|
+
preferred for new code; the concern form fits codebases with established
|
|
871
|
+
mixin-based feature wiring.
|
|
872
|
+
|
|
873
|
+
### Querying history
|
|
874
|
+
|
|
875
|
+
```ruby
|
|
876
|
+
contact.typed_eav_attributes = [{ name: "age", value: 41 }]
|
|
877
|
+
contact.save!
|
|
878
|
+
contact.typed_eav_attributes = [{ name: "age", value: 42 }]
|
|
879
|
+
contact.save!
|
|
880
|
+
|
|
881
|
+
value = contact.typed_values.find_by(field: age_field)
|
|
882
|
+
value.history # most-recent-first relation
|
|
883
|
+
# => [<ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42}>,
|
|
884
|
+
# <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41}>]
|
|
885
|
+
|
|
886
|
+
value.history.first.changed_by # => "42" (User#42 — coerced to id.to_s)
|
|
887
|
+
value.history.first.context # => { "request_id" => "abc-123" } if with_context was active
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
`value.history` is a chainable relation. Filter, paginate, pluck:
|
|
891
|
+
|
|
892
|
+
```ruby
|
|
893
|
+
value.history.where(change_type: "update").pluck(:changed_at, :changed_by)
|
|
894
|
+
value.history.limit(5).each { |v| ... }
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
### Querying full audit history (including destroy events)
|
|
898
|
+
|
|
899
|
+
`Value#history` returns versions where `value_id` matches the live Value
|
|
900
|
+
record. After the live Value is destroyed, the FK `ON DELETE SET NULL`
|
|
901
|
+
nullifies `value_id` on the existing version rows, and the new `:destroy`
|
|
902
|
+
version is also written with `value_id: nil` (the parent
|
|
903
|
+
`typed_eav_values` row is gone by `after_commit on: :destroy` time —
|
|
904
|
+
writing a non-nil `value_id` would FK-fail at INSERT). So `Value#history`
|
|
905
|
+
cannot surface destroy versions, and after Value destruction it can no
|
|
906
|
+
longer be called at all.
|
|
907
|
+
|
|
908
|
+
To query the FULL audit history for a given (entity, field), including
|
|
909
|
+
destroy events and post-destruction lookup, use the entity-scoped query
|
|
910
|
+
directly:
|
|
911
|
+
|
|
912
|
+
```ruby
|
|
913
|
+
TypedEAV::ValueVersion
|
|
914
|
+
.where(entity_type: contact.class.name, entity_id: contact.id, field_id: age_field.id)
|
|
915
|
+
.order(changed_at: :desc, id: :desc)
|
|
916
|
+
# => [<ValueVersion change_type: "destroy" before: {"integer_value" => 42} after: {} value_id: nil>,
|
|
917
|
+
# <ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42} value_id: nil>,
|
|
918
|
+
# <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41} value_id: nil>]
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
This pattern is the canonical way to surface "what happened to this
|
|
922
|
+
field on this entity" across the full lifecycle, including post-destroy.
|
|
923
|
+
The `entity_type` + `entity_id` columns remain the durable identity even
|
|
924
|
+
after the parent Value row is gone, and `field_id` survives because
|
|
925
|
+
destroying a Value does not destroy its Field.
|
|
926
|
+
|
|
927
|
+
For broader audit views — "show all version history across all fields
|
|
928
|
+
for a given entity" (e.g., admin entity-history pages, compliance
|
|
929
|
+
exports) — drop the `field_id` filter:
|
|
930
|
+
|
|
931
|
+
```ruby
|
|
932
|
+
TypedEAV::ValueVersion
|
|
933
|
+
.where(entity_type: contact.class.name, entity_id: contact.id)
|
|
934
|
+
.order(changed_at: :desc, id: :desc)
|
|
935
|
+
# => all version rows for every typed field on this contact, most-recent-first.
|
|
936
|
+
# Includes :create, :update, and :destroy events across every field the
|
|
937
|
+
# entity has ever had a typed value for.
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
The field-scoped query (with `field_id:`) is the common case for
|
|
941
|
+
"history of a single field"; the entity-scoped query (without `field_id:`)
|
|
942
|
+
is the broad-audit case for "all version history across all fields for
|
|
943
|
+
this entity".
|
|
944
|
+
|
|
945
|
+
### Version row jsonb shape
|
|
946
|
+
|
|
947
|
+
`before_value` and `after_value` are jsonb hashes keyed by typed-column
|
|
948
|
+
name:
|
|
949
|
+
|
|
950
|
+
| Field type | Snapshot shape (single key) |
|
|
951
|
+
|---|---|
|
|
952
|
+
| `text`, `email`, `url`, `color` | `{"string_value": "..."}` |
|
|
953
|
+
| `long_text` | `{"text_value": "..."}` |
|
|
954
|
+
| `integer` | `{"integer_value": 42}` |
|
|
955
|
+
| `decimal` | `{"decimal_value": "10.5"}` |
|
|
956
|
+
| `boolean` | `{"boolean_value": true}` |
|
|
957
|
+
| `date` | `{"date_value": "2026-05-05"}` |
|
|
958
|
+
| `date_time` | `{"datetime_value": "2026-05-05T12:00:00Z"}` |
|
|
959
|
+
| `select` | `{"string_value": "..."}` |
|
|
960
|
+
| `multi_select`, `*_array`, `json` | `{"json_value": [...]}` |
|
|
961
|
+
|
|
962
|
+
Multi-cell field types (e.g., `Currency`) produce two-key snapshots:
|
|
963
|
+
`{"decimal_value": "99.99", "string_value": "USD"}`. The version row's
|
|
964
|
+
snapshot asks the field's storage contract for its cells, so new field
|
|
965
|
+
types get the right shape automatically.
|
|
966
|
+
|
|
967
|
+
`{}` (empty hash) and `{"<col>": null}` are distinct semantics:
|
|
968
|
+
|
|
969
|
+
- `{}` means **no recorded value** — typical of `before_value` on a
|
|
970
|
+
`:create` event, or `after_value` on a `:destroy` event.
|
|
971
|
+
- `{"<col>": null}` means **recorded nil** — the user explicitly
|
|
972
|
+
cleared the cell.
|
|
973
|
+
|
|
974
|
+
### Reverting
|
|
975
|
+
|
|
976
|
+
```ruby
|
|
977
|
+
target = value.history.find_by(change_type: "update")
|
|
978
|
+
value.revert_to(target)
|
|
979
|
+
# value's typed columns now match target.before_value.
|
|
980
|
+
# A NEW version row is written capturing the revert (append-only).
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
`revert_to` writes the targeted version's `before_value` columns back
|
|
984
|
+
via `self[col] = …` and `save!`. The existing `after_commit` chain
|
|
985
|
+
fires; the versioning subscriber writes a NEW version row whose
|
|
986
|
+
`after_value` reflects the targeted version's `before_value`. The
|
|
987
|
+
audit log is append-only — every revert is itself versioned.
|
|
988
|
+
|
|
989
|
+
To record the intent of the revert, wrap the call in `with_context`:
|
|
990
|
+
|
|
991
|
+
```ruby
|
|
992
|
+
TypedEAV.with_context(reverted_from_version_id: target.id, actor: current_user) do
|
|
993
|
+
value.revert_to(target)
|
|
994
|
+
end
|
|
995
|
+
# The new version row's `context` column captures both keys.
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
`revert_to` raises `ArgumentError` in three documented conditions, checked in order:
|
|
999
|
+
|
|
1000
|
+
- when `version.value_id` is nil (the source Value was destroyed — destroy
|
|
1001
|
+
versions have `value_id: nil` per the locked subscriber contract; you
|
|
1002
|
+
can't restore a destroyed AR record by `save!`);
|
|
1003
|
+
- when the version's `before_value` is empty (the version represents a
|
|
1004
|
+
`:create` event with no before-state to revert to);
|
|
1005
|
+
- when the version belongs to a different Value (`value_id` mismatch).
|
|
1006
|
+
|
|
1007
|
+
In practice only `:update` versions are revertable. To restore a
|
|
1008
|
+
destroyed entity's typed values, create a new `TypedEAV::Value` record
|
|
1009
|
+
manually using `version.before_value` as the seed state.
|
|
1010
|
+
|
|
1011
|
+
### Hook ordering guarantee
|
|
1012
|
+
|
|
1013
|
+
Versioning is registered as an internal subscriber on
|
|
1014
|
+
`TypedEAV::EventDispatcher`. It runs **first** (slot 0) for every Value
|
|
1015
|
+
event. Your `Config.on_value_change` user proc fires **last**, after
|
|
1016
|
+
the version row is persisted:
|
|
1017
|
+
|
|
1018
|
+
```
|
|
1019
|
+
Value#save! → after_commit → EventDispatcher.dispatch_value_change:
|
|
1020
|
+
1. TypedEAV::Versioning::Subscriber.call # writes version row
|
|
1021
|
+
2. ... any other internal subscribers (Phase 7 matview, etc.) ...
|
|
1022
|
+
3. Config.on_value_change user proc # sees the persisted version
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
Internal subscriber errors propagate (versioning corruption is loud).
|
|
1026
|
+
User proc errors are rescued and logged via `Rails.logger.error` —
|
|
1027
|
+
the save itself already committed.
|
|
1028
|
+
|
|
1029
|
+
### Actor resolution
|
|
1030
|
+
|
|
1031
|
+
`Config.actor_resolver` mirrors `Config.scope_resolver`'s callable shape
|
|
1032
|
+
but returns whatever the app chooses (an AR record, a string, an integer,
|
|
1033
|
+
nil). The subscriber coerces non-nil returns via `id.to_s` (for AR
|
|
1034
|
+
records) or `to_s` (for scalars) before storing in the `changed_by`
|
|
1035
|
+
column (string, nullable).
|
|
1036
|
+
|
|
1037
|
+
`nil` is the documented permissive sentinel: system writes, migrations,
|
|
1038
|
+
console-without-actor, and background jobs without a `with_context(actor:
|
|
1039
|
+
...)` wrap all flow through with `changed_by: nil`. This is intentional —
|
|
1040
|
+
forcing every Versioned write to have an actor would reject every console
|
|
1041
|
+
save and every migration backfill, which is hostile-by-default for a gem.
|
|
1042
|
+
|
|
1043
|
+
Apps that need stricter enforcement do it inside the resolver:
|
|
1044
|
+
|
|
1045
|
+
```ruby
|
|
1046
|
+
c.actor_resolver = -> { Current.user || raise(MyApp::ActorRequired) }
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
`Config.reset!` (documented in §"Event hooks") also resets `Config.versioning`
|
|
1050
|
+
to `false` and `Config.actor_resolver` to `nil`.
|
|
1051
|
+
|
|
1052
|
+
### What versioning does not do
|
|
1053
|
+
|
|
1054
|
+
- **No branching/merging across version chains.** Phase 4 ships event-log
|
|
1055
|
+
shape only. Roadmap explicitly defers branching to a future design.
|
|
1056
|
+
- **No snapshot storage by default.** `typed_eav_value_versions` is an
|
|
1057
|
+
event log — one row per change, not a full-row snapshot. For
|
|
1058
|
+
high-volume apps that want snapshot storage, extend `ValueVersion` in
|
|
1059
|
+
your own code (the gem keeps the event-log shape canonical so future
|
|
1060
|
+
upgrades don't break your extension).
|
|
1061
|
+
- **No automatic `reverted_from_version_id` injection.** Use
|
|
1062
|
+
`with_context` to record revert intent; the gem captures whatever
|
|
1063
|
+
context the caller set.
|
|
1064
|
+
- **No per-Field versioning toggle.** Opt-in is per-entity (host model)
|
|
1065
|
+
in Phase 4. Per-field granularity may land later if a real need
|
|
1066
|
+
surfaces.
|
|
1067
|
+
- **No GIN indexes on `before_value` / `after_value` content.** Apps
|
|
1068
|
+
that need to query inside the snapshot jsonb add their own indexes.
|
|
1069
|
+
Phase 4 ships only the temporal indexes (`changed_at DESC` keyed on
|
|
1070
|
+
`value_id`, `(entity_type, entity_id)`, and `field_id`).
|
|
1071
|
+
|
|
1072
|
+
### Test isolation
|
|
1073
|
+
|
|
1074
|
+
Specs that exercise versioning should opt into the `:event_callbacks`
|
|
1075
|
+
and `:real_commits` metadata flags (see §"Event hooks" — same pattern):
|
|
1076
|
+
|
|
1077
|
+
```ruby
|
|
1078
|
+
RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
|
|
1079
|
+
before do
|
|
1080
|
+
TypedEAV.registry.register("Contact", versioned: true)
|
|
1081
|
+
TypedEAV::Config.versioning = true
|
|
1082
|
+
# CRITICAL: the :event_callbacks hook clears
|
|
1083
|
+
# EventDispatcher.value_change_internals at example entry, so the
|
|
1084
|
+
# engine-boot-registered subscriber is gone for the duration of
|
|
1085
|
+
# the example. Re-register explicitly inside the before block.
|
|
1086
|
+
# The hook's ensure block restores the snapshot — no leak.
|
|
1087
|
+
TypedEAV::EventDispatcher.register_internal_value_change(
|
|
1088
|
+
TypedEAV::Versioning::Subscriber.method(:call),
|
|
1089
|
+
)
|
|
1090
|
+
end
|
|
1091
|
+
after { TypedEAV.registry.register("Contact", versioned: false) }
|
|
1092
|
+
|
|
1093
|
+
it "writes a version row" do
|
|
1094
|
+
# ...
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshot/
|
|
1100
|
+
restores `Config.versioning`, `Config.actor_resolver`, and the
|
|
1101
|
+
EventDispatcher subscriber lists around each example, so your changes
|
|
1102
|
+
don't leak to subsequent tests. The snapshot/restore CLEARS the
|
|
1103
|
+
internals list at example entry — that's why the re-registration
|
|
1104
|
+
above is required for any spec that needs the subscriber to fire. The
|
|
1105
|
+
`:real_commits` hook disables transactional fixtures (so `after_commit`
|
|
1106
|
+
fires durably) and cleans up `TypedEAV::ValueVersion` rows in
|
|
1107
|
+
FK-respecting order between examples.
|
|
478
1108
|
|
|
479
1109
|
## Database Support
|
|
480
1110
|
|
|
481
1111
|
Requires PostgreSQL. The `text_pattern_ops` index on `string_value` and the jsonb `@>` containment operator are Postgres-specific. MySQL/SQLite support would require removing those index types and changing the array query operators.
|
|
482
1112
|
|
|
1113
|
+
As of v0.2.0, the paired partial unique indexes cover the three-key partition tuple `(entity_type, scope, parent_scope)`. The orphan-parent invariant means the `WHERE scope IS NULL` partials don't include `parent_scope` — a global row always has `parent_scope` NULL too.
|
|
1114
|
+
|
|
483
1115
|
## Schema
|
|
484
1116
|
|
|
485
1117
|
The gem creates four tables:
|