typed_eav 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/MIT-LICENSE +21 -0
- data/README.md +494 -0
- data/Rakefile +13 -0
- data/app/models/typed_eav/application_record.rb +7 -0
- data/app/models/typed_eav/field/base.rb +234 -0
- data/app/models/typed_eav/field/boolean.rb +22 -0
- data/app/models/typed_eav/field/color.rb +16 -0
- data/app/models/typed_eav/field/date.rb +24 -0
- data/app/models/typed_eav/field/date_array.rb +34 -0
- data/app/models/typed_eav/field/date_time.rb +29 -0
- data/app/models/typed_eav/field/decimal.rb +30 -0
- data/app/models/typed_eav/field/decimal_array.rb +31 -0
- data/app/models/typed_eav/field/email.rb +40 -0
- data/app/models/typed_eav/field/integer.rb +30 -0
- data/app/models/typed_eav/field/integer_array.rb +68 -0
- data/app/models/typed_eav/field/json.rb +26 -0
- data/app/models/typed_eav/field/long_text.rb +19 -0
- data/app/models/typed_eav/field/multi_select.rb +41 -0
- data/app/models/typed_eav/field/select.rb +28 -0
- data/app/models/typed_eav/field/text.rb +41 -0
- data/app/models/typed_eav/field/text_array.rb +36 -0
- data/app/models/typed_eav/field/url.rb +40 -0
- data/app/models/typed_eav/option.rb +24 -0
- data/app/models/typed_eav/section.rb +25 -0
- data/app/models/typed_eav/value.rb +149 -0
- data/db/migrate/20260330000000_create_typed_eav_tables.rb +132 -0
- data/lib/generators/typed_eav/install/install_generator.rb +28 -0
- data/lib/generators/typed_eav/scaffold/scaffold_generator.rb +106 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +45 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/concerns/typed_eav_controller_concern.rb +24 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +231 -0
- data/lib/generators/typed_eav/scaffold/templates/helpers/typed_eav_helper.rb +150 -0
- data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js +64 -0
- data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js +32 -0
- data/lib/generators/typed_eav/scaffold/templates/views/shared/_array_field.html.erb +23 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/edit.html.erb +47 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/finders/_form.html.erb +80 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_boolean.html.erb +12 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_color.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +57 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_time.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_email.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_json.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_long_text.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_multi_select.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_select.html.erb +14 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text.html.erb +26 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_url.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +42 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/new.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +44 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_boolean.html.erb +10 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_color.html.erb +4 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_time.html.erb +5 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_email.html.erb +5 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_json.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_long_text.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_multi_select.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_select.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb +5 -0
- data/lib/typed_eav/column_mapping.rb +64 -0
- data/lib/typed_eav/config.rb +91 -0
- data/lib/typed_eav/engine.rb +20 -0
- data/lib/typed_eav/has_typed_eav.rb +484 -0
- data/lib/typed_eav/query_builder.rb +133 -0
- data/lib/typed_eav/registry.rb +52 -0
- data/lib/typed_eav/version.rb +5 -0
- data/lib/typed_eav.rb +86 -0
- metadata +146 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d634ac47d7ecea149ffb2d61e837db1a59d7a5dd37f9f4e820baca35a4e5e6c6
|
|
4
|
+
data.tar.gz: 507b9bfe48fb62694230196c838cd683050f770d711412d0b56fab35e93cfb5c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 714f43c7d48e4592222298df3ba7065ee40efce7a6e31c49f1342aaa09ae7121082a48e4572035ff173562e077d723bbad5d9dd81a8da01305331ecc7d7ed795
|
|
7
|
+
data.tar.gz: 54ee629ae6662faad51aa3357e194455bec33a3d30be5d2675b1d1ddfcc25e386b3d8e7a0cf6786bfb7bc0c2ce4de1b5a52e8444e12591ce7d3a5482cd4807d4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-04-25
|
|
9
|
+
|
|
10
|
+
Initial release.
|
|
11
|
+
|
|
12
|
+
[0.1.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.1.0
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Darrin Chuk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
# TypedEAV
|
|
2
|
+
|
|
3
|
+
Add dynamic custom fields to ActiveRecord models at runtime, backed by **native database typed columns** instead of jsonb blobs.
|
|
4
|
+
|
|
5
|
+
TypedEAV uses a hybrid EAV (Entity-Attribute-Value) pattern where each value type gets its own column (`integer_value`, `date_value`, `string_value`, etc.) in the values table. This means the database can natively index, sort, and enforce constraints on your custom field data with zero runtime type casting.
|
|
6
|
+
|
|
7
|
+
## Why Typed Columns?
|
|
8
|
+
|
|
9
|
+
Most Rails custom field gems serialize everything into a single `jsonb` column. When you query, they generate SQL like:
|
|
10
|
+
|
|
11
|
+
```sql
|
|
12
|
+
CAST(value_meta->>'const' AS bigint) = 42
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This works, but:
|
|
16
|
+
|
|
17
|
+
- **No B-tree indexes** on the actual values (only GIN for jsonb containment)
|
|
18
|
+
- **Runtime CAST overhead** on every query
|
|
19
|
+
- **No database-level type enforcement** (a "number" could be stored as a string)
|
|
20
|
+
- **The query planner can't optimize** range scans, sorts, or joins
|
|
21
|
+
|
|
22
|
+
TypedEAV stores values in native columns, so queries become:
|
|
23
|
+
|
|
24
|
+
```sql
|
|
25
|
+
WHERE integer_value = 42
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Standard B-tree indexes work. Range scans work. The query planner is happy. ActiveRecord handles all type casting automatically through the column's registered type.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Add to your Gemfile:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
gem "typed_eav"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Run the install migration:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bin/rails typed_eav:install:migrations
|
|
42
|
+
bin/rails db:migrate
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
### 1. Include the concern
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class Contact < ApplicationRecord
|
|
51
|
+
has_typed_eav
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# With multi-tenant scoping:
|
|
55
|
+
class Contact < ApplicationRecord
|
|
56
|
+
has_typed_eav scope_method: :tenant_id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# With restricted field types:
|
|
60
|
+
class Contact < ApplicationRecord
|
|
61
|
+
has_typed_eav types: [:text, :integer, :boolean, :select]
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 2. Create field definitions
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# Simple fields
|
|
69
|
+
TypedEAV::Field::Text.create!(
|
|
70
|
+
name: "nickname",
|
|
71
|
+
entity_type: "Contact"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
TypedEAV::Field::Integer.create!(
|
|
75
|
+
name: "age",
|
|
76
|
+
entity_type: "Contact",
|
|
77
|
+
required: true,
|
|
78
|
+
options: { min: 0, max: 150 }
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
TypedEAV::Field::Date.create!(
|
|
82
|
+
name: "birthday",
|
|
83
|
+
entity_type: "Contact",
|
|
84
|
+
options: { max_date: Date.today.to_s }
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Select field with options
|
|
88
|
+
status = TypedEAV::Field::Select.create!(
|
|
89
|
+
name: "status",
|
|
90
|
+
entity_type: "Contact",
|
|
91
|
+
required: true
|
|
92
|
+
)
|
|
93
|
+
status.field_options.create!([
|
|
94
|
+
{ label: "Active", value: "active", sort_order: 1 },
|
|
95
|
+
{ label: "Inactive", value: "inactive", sort_order: 2 },
|
|
96
|
+
{ label: "Lead", value: "lead", sort_order: 3 },
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
# Multi-select (stored as json array)
|
|
100
|
+
tags = TypedEAV::Field::MultiSelect.create!(
|
|
101
|
+
name: "tags",
|
|
102
|
+
entity_type: "Contact"
|
|
103
|
+
)
|
|
104
|
+
tags.field_options.create!([
|
|
105
|
+
{ label: "VIP", value: "vip" },
|
|
106
|
+
{ label: "Partner", value: "partner" },
|
|
107
|
+
{ label: "Prospect", value: "prospect" },
|
|
108
|
+
])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 3. Set values on records
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
contact = Contact.new(name: "Darrin")
|
|
115
|
+
|
|
116
|
+
# Individual assignment
|
|
117
|
+
contact.set_typed_eav_value("age", 40)
|
|
118
|
+
contact.set_typed_eav_value("status", "active")
|
|
119
|
+
|
|
120
|
+
# Bulk assignment by field NAME (ergonomic for scripting / seeds)
|
|
121
|
+
contact.typed_eav_attributes = [
|
|
122
|
+
{ name: "age", value: 40 },
|
|
123
|
+
{ name: "status", value: "active" },
|
|
124
|
+
{ name: "tags", value: ["vip", "partner"] },
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
# Bulk assignment by field ID (standard Rails form contract).
|
|
128
|
+
# Your form templates emit this shape when you use fields_for :typed_values.
|
|
129
|
+
contact.typed_values_attributes = [
|
|
130
|
+
{ id: 12, field_id: 4, value: "40" },
|
|
131
|
+
{ field_id: 7, value: "active" },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
contact.save!
|
|
135
|
+
|
|
136
|
+
# Reading
|
|
137
|
+
contact.typed_eav_value("age") # => 40 (Ruby Integer)
|
|
138
|
+
contact.typed_eav_value("status") # => "active"
|
|
139
|
+
contact.typed_eav_hash # => { "age" => 40, "status" => "active", ... }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### 4. Query with the DSL
|
|
143
|
+
|
|
144
|
+
This is where typed columns pay off. All queries go through native columns with proper indexes.
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# Short form - single field filter
|
|
148
|
+
Contact.with_field("age", :gt, 21)
|
|
149
|
+
Contact.with_field("status", "active") # :eq is the default operator
|
|
150
|
+
Contact.with_field("nickname", :contains, "smith")
|
|
151
|
+
|
|
152
|
+
# Chain them
|
|
153
|
+
Contact.with_field("age", :gteq, 18)
|
|
154
|
+
.with_field("status", "active")
|
|
155
|
+
.with_field("tags", :any_eq, "vip")
|
|
156
|
+
|
|
157
|
+
# Multi-filter form (good for search UIs)
|
|
158
|
+
Contact.where_typed_eav(
|
|
159
|
+
{ name: "age", op: :gt, value: 21 },
|
|
160
|
+
{ name: "status", op: :eq, value: "active" },
|
|
161
|
+
{ name: "city", op: :contains, value: "port" },
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Compact keys (for URL params / form submissions)
|
|
165
|
+
Contact.where_typed_eav(
|
|
166
|
+
{ n: "age", op: :gt, v: 21 },
|
|
167
|
+
{ n: "status", v: "active" },
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# With scoping
|
|
171
|
+
Contact.where_typed_eav(
|
|
172
|
+
{ name: "priority", op: :eq, value: "high" },
|
|
173
|
+
scope: current_tenant.id
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Combine with standard ActiveRecord
|
|
177
|
+
Contact.where(company_id: 42)
|
|
178
|
+
.with_field("status", "active")
|
|
179
|
+
.with_field("age", :gteq, 21)
|
|
180
|
+
.order(:name)
|
|
181
|
+
.limit(25)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Available Operators
|
|
185
|
+
|
|
186
|
+
| Operator | Works On | Description |
|
|
187
|
+
|----------|----------|-------------|
|
|
188
|
+
| `:eq` | all | Equal (default) |
|
|
189
|
+
| `:not_eq` | all | Not equal (NULL-safe) |
|
|
190
|
+
| `:gt` | numeric, date, datetime | Greater than |
|
|
191
|
+
| `:gteq` | numeric, date, datetime | Greater than or equal |
|
|
192
|
+
| `:lt` | numeric, date, datetime | Less than |
|
|
193
|
+
| `:lteq` | numeric, date, datetime | Less than or equal |
|
|
194
|
+
| `:between` | numeric, date, datetime | Between (pass Range or Array) |
|
|
195
|
+
| `:contains` | text, long_text | ILIKE %value% |
|
|
196
|
+
| `:not_contains` | text, long_text | NOT ILIKE %value% |
|
|
197
|
+
| `:starts_with` | text, long_text | ILIKE value% |
|
|
198
|
+
| `:ends_with` | text, long_text | ILIKE %value |
|
|
199
|
+
| `:any_eq` | json arrays | Array contains element |
|
|
200
|
+
| `:all_eq` | json arrays | Array contains all elements |
|
|
201
|
+
| `:is_null` | all | Value is NULL |
|
|
202
|
+
| `:is_not_null` | all | Value is not NULL |
|
|
203
|
+
|
|
204
|
+
### How Type Inference Works
|
|
205
|
+
|
|
206
|
+
You don't need to think about types when querying. Rails handles it:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# You pass a string, Rails casts to integer via the column type
|
|
210
|
+
Contact.with_field("age", :gt, "21")
|
|
211
|
+
# SQL: WHERE integer_value > 21 (not '21')
|
|
212
|
+
|
|
213
|
+
# You pass a string, Rails casts to date
|
|
214
|
+
Contact.with_field("birthday", :lt, "2000-01-01")
|
|
215
|
+
# SQL: WHERE date_value < '2000-01-01'::date
|
|
216
|
+
|
|
217
|
+
# Boolean columns handle truthy/falsy casting
|
|
218
|
+
Contact.with_field("active", "true")
|
|
219
|
+
# SQL: WHERE boolean_value = TRUE
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
This works because `ActiveRecord::Base.columns_hash` knows every column's type from the schema, and `where()` / Arel predicates automatically cast values through the column's registered `ActiveRecord::Type`.
|
|
223
|
+
|
|
224
|
+
## Forms
|
|
225
|
+
|
|
226
|
+
Wire typed fields into Rails forms via nested attributes:
|
|
227
|
+
|
|
228
|
+
```erb
|
|
229
|
+
<%= form_with model: @contact do |f| %>
|
|
230
|
+
<%= f.text_field :name %>
|
|
231
|
+
|
|
232
|
+
<%= render_typed_value_inputs(form: f, record: @contact) %>
|
|
233
|
+
|
|
234
|
+
<%= f.submit %>
|
|
235
|
+
<% end %>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The helper emits one input per available field, including the hidden `id` / `field_id` markers required by `accepts_nested_attributes_for`. Permit the nested shape in your controller — the `value: []` form is required for array/multi-select types:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
def contact_params
|
|
242
|
+
params.require(:contact).permit(
|
|
243
|
+
:name,
|
|
244
|
+
typed_values_attributes: [
|
|
245
|
+
:id, :field_id, :_destroy, :value, { value: [] }
|
|
246
|
+
]
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
For list pages, preload the field association to avoid N+1:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
@contacts = Contact.includes(typed_values: :field).all
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Admin Scaffold
|
|
258
|
+
|
|
259
|
+
To manage field definitions through a UI, run the scaffold generator:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
bin/rails g typed_eav:scaffold
|
|
263
|
+
bin/rails db:migrate
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
This copies a controller, views, helper, Stimulus controllers, and an initializer into your app, and adds routes mounted at `/typed_eav_fields`.
|
|
267
|
+
|
|
268
|
+
**Security**: the generated controller ships with `authorize_typed_eav_admin!` returning `head :not_found` by default — fail-closed. Edit the method directly in `app/controllers/typed_eav_controller.rb` to wire it to your auth system:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
def authorize_typed_eav_admin!
|
|
272
|
+
return if current_user&.admin?
|
|
273
|
+
head :not_found
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Defining `authorize_typed_eav_admin!` in `ApplicationController` does **not** override it — the scaffold sets it on its own controller.
|
|
278
|
+
|
|
279
|
+
## Multi-Tenant Scoping
|
|
280
|
+
|
|
281
|
+
Field definitions are partitioned by a `scope` column so multiple tenants (or accounts, workspaces, orgs — any partition key your app uses) can each define their own fields without collisions. Fields with `scope = NULL` are global, visible to every partition.
|
|
282
|
+
|
|
283
|
+
### Declaring a scoped model
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
class Contact < ApplicationRecord
|
|
287
|
+
has_typed_eav scope_method: :tenant_id
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`scope_method:` names an instance method on your model. When the record reads its own field definitions (e.g., in a form), that method tells TypedEAV which partition the record belongs to.
|
|
292
|
+
|
|
293
|
+
### Class-level queries resolve scope automatically
|
|
294
|
+
|
|
295
|
+
Queries like `Contact.where_typed_eav(...)` consult an **ambient scope resolver** — no need to pass `scope:` on every call:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
# The resolver tells TypedEAV which partition is active.
|
|
299
|
+
Contact.where_typed_eav({ name: "age", op: :gt, value: 21 })
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The resolver chain (highest priority first):
|
|
303
|
+
|
|
304
|
+
1. Explicit `scope:` keyword argument on the query
|
|
305
|
+
2. Active `TypedEAV.with_scope(value) { ... }` block
|
|
306
|
+
3. Configured `TypedEAV.config.scope_resolver` callable
|
|
307
|
+
4. `nil`
|
|
308
|
+
|
|
309
|
+
If every step returns `nil` and the model declared `scope_method:`, queries raise `TypedEAV::ScopeRequired` — the **fail-closed default**. This is the whole point: forgetting to set scope can't silently leak other partitions' data.
|
|
310
|
+
|
|
311
|
+
### Wiring the resolver
|
|
312
|
+
|
|
313
|
+
Pick the pattern that matches your app and set it once in `config/initializers/typed_eav.rb`:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
TypedEAV.configure do |c|
|
|
317
|
+
# acts_as_tenant (auto-detected — no config needed if loaded)
|
|
318
|
+
# c.scope_resolver = -> { ActsAsTenant.current_tenant&.id }
|
|
319
|
+
|
|
320
|
+
# Rails CurrentAttributes
|
|
321
|
+
# c.scope_resolver = -> { Current.account&.id }
|
|
322
|
+
|
|
323
|
+
# Custom class
|
|
324
|
+
# c.scope_resolver = -> { MyApp::Tenancy.current_workspace_id }
|
|
325
|
+
|
|
326
|
+
# Subdomain / session / thread-local
|
|
327
|
+
# c.scope_resolver = -> { Thread.current[:org_id] }
|
|
328
|
+
|
|
329
|
+
# Disable ambient resolution entirely
|
|
330
|
+
# c.scope_resolver = nil
|
|
331
|
+
|
|
332
|
+
c.require_scope = true # fail-closed (default). Set false for gradual adoption.
|
|
333
|
+
end
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The resolver can return a raw value (`"t1"`, `42`) or an AR record — TypedEAV calls `.id.to_s` when the return value responds to `#id`.
|
|
337
|
+
|
|
338
|
+
### Block APIs
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
# Run a block with a specific ambient scope (background jobs, console, rake tasks):
|
|
342
|
+
TypedEAV.with_scope(tenant_id) do
|
|
343
|
+
Contact.where_typed_eav({ name: "status", op: :eq, value: "active" })
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Escape hatch for admin tools, migrations, or cross-tenant audits:
|
|
347
|
+
TypedEAV.unscoped do
|
|
348
|
+
Contact.where_typed_eav({ name: "status", op: :eq, value: "active" })
|
|
349
|
+
# returns matches across ALL partitions
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Both are exception-safe via `ensure` and nest cleanly.
|
|
354
|
+
|
|
355
|
+
### Explicit `scope:` override
|
|
356
|
+
|
|
357
|
+
Any query method accepts `scope:` as an override for admin tools and tests:
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
Contact.where_typed_eav({ name: "status", value: "active" }, scope: "t1")
|
|
361
|
+
Contact.with_field("age", :gt, 21, scope: "t1")
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Explicit wins over ambient. Passing `scope: nil` explicitly (as opposed to omitting the kwarg) means "filter to global fields only" — useful for admin UIs that want to see unscoped field definitions without activating `unscoped` mode.
|
|
365
|
+
|
|
366
|
+
### Background jobs
|
|
367
|
+
|
|
368
|
+
ActiveJob (including Sidekiq via the ActiveJob adapter) wraps every `perform` in Rails' executor, which already clears `ActiveSupport::CurrentAttributes` between jobs — so if your resolver reads from `Current.account`, each job starts clean. For raw `Sidekiq::Job` (no ActiveJob), wrap the job body manually:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
class ExportJob
|
|
372
|
+
include Sidekiq::Job
|
|
373
|
+
|
|
374
|
+
def perform(tenant_id, ...)
|
|
375
|
+
TypedEAV.with_scope(tenant_id) do
|
|
376
|
+
Contact.where_typed_eav(...)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Disabling enforcement for gradual adoption
|
|
383
|
+
|
|
384
|
+
If your app has existing typed-eav queries that don't yet pass scope, flip `require_scope` to `false` in the initializer. When no scope resolves, queries fall back to **global fields only** (definitions stored with `scope: nil`) instead of raising — they do **not** return all partitions' fields. Audit and fix callers, then flip back to `true`.
|
|
385
|
+
|
|
386
|
+
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
|
+
|
|
388
|
+
### Name collisions across scopes
|
|
389
|
+
|
|
390
|
+
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.
|
|
391
|
+
|
|
392
|
+
`TypedEAV.unscoped { Contact.where_typed_eav(...) }` OR-across every partition's matching `field_id` per filter (still AND-ing across filters), so cross-tenant audit queries see every partition's matches — they don't collapse to a single tenant.
|
|
393
|
+
|
|
394
|
+
## Field Types
|
|
395
|
+
|
|
396
|
+
| Type | Column | Ruby Type | Options |
|
|
397
|
+
|------|--------|-----------|---------|
|
|
398
|
+
| `Text` | `string_value` | String | `min_length`, `max_length`, `pattern` |
|
|
399
|
+
| `LongText` | `text_value` | String | `min_length`, `max_length` |
|
|
400
|
+
| `Integer` | `integer_value` | Integer | `min`, `max` |
|
|
401
|
+
| `Decimal` | `decimal_value` | BigDecimal | `min`, `max`, `precision_scale` |
|
|
402
|
+
| `Boolean` | `boolean_value` | Boolean | |
|
|
403
|
+
| `Date` | `date_value` | Date | `min_date`, `max_date` |
|
|
404
|
+
| `DateTime` | `datetime_value` | Time | `min_datetime`, `max_datetime` |
|
|
405
|
+
| `Select` | `string_value` | String | options via `TypedEAV::Option` |
|
|
406
|
+
| `MultiSelect` | `json_value` | Array | options via `TypedEAV::Option` |
|
|
407
|
+
| `IntegerArray` | `json_value` | Array | `min_size`, `max_size`, `min`, `max` |
|
|
408
|
+
| `DecimalArray` | `json_value` | Array | `min_size`, `max_size` |
|
|
409
|
+
| `TextArray` | `json_value` | Array | `min_size`, `max_size` |
|
|
410
|
+
| `DateArray` | `json_value` | Array | `min_size`, `max_size` |
|
|
411
|
+
| `Email` | `string_value` | String | auto-downcases, strips whitespace |
|
|
412
|
+
| `Url` | `string_value` | String | strips whitespace |
|
|
413
|
+
| `Color` | `string_value` | String | hex color values |
|
|
414
|
+
| `Json` | `json_value` | Hash/Array | arbitrary JSON |
|
|
415
|
+
|
|
416
|
+
## Sections (Optional UI Grouping)
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
general = TypedEAV::Section.create!(
|
|
420
|
+
name: "General Info",
|
|
421
|
+
code: "general",
|
|
422
|
+
entity_type: "Contact",
|
|
423
|
+
sort_order: 1
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
social = TypedEAV::Section.create!(
|
|
427
|
+
name: "Social Media",
|
|
428
|
+
code: "social",
|
|
429
|
+
entity_type: "Contact",
|
|
430
|
+
sort_order: 2
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
TypedEAV::Field::Text.create!(
|
|
434
|
+
name: "twitter_handle",
|
|
435
|
+
entity_type: "Contact",
|
|
436
|
+
section: social
|
|
437
|
+
)
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## Custom Field Types
|
|
441
|
+
|
|
442
|
+
Override `cast(raw)` to return a `[casted_value, invalid?]` tuple.
|
|
443
|
+
`invalid?` tells `Value#validate_value` whether to surface `:invalid`
|
|
444
|
+
(vs `:blank`) when raw input can't be coerced. For types that never
|
|
445
|
+
fail to coerce, always return `[value, false]`.
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
# app/models/fields/phone.rb
|
|
449
|
+
module Fields
|
|
450
|
+
class Phone < TypedEAV::Field::Base
|
|
451
|
+
value_column :string_value
|
|
452
|
+
operators :eq, :contains, :starts_with, :is_null, :is_not_null
|
|
453
|
+
|
|
454
|
+
def cast(raw)
|
|
455
|
+
# Strip everything but digits and +; never rejects as invalid
|
|
456
|
+
[raw&.to_s&.gsub(/[^\d+]/, ""), false]
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Register it
|
|
462
|
+
TypedEAV.configure do |c|
|
|
463
|
+
c.register_field_type :phone, "Fields::Phone"
|
|
464
|
+
end
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
## Validation Behavior
|
|
468
|
+
|
|
469
|
+
A few non-obvious contracts worth knowing about up front:
|
|
470
|
+
|
|
471
|
+
- **Required + blank**: `required: true` fields reject empty strings, whitespace-only strings, and arrays whose every element is nil/blank/whitespace.
|
|
472
|
+
- **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.
|
|
473
|
+
- **`Integer` array rejects fractional input**: `"1.9"` is rejected rather than truncated to `1`. Same rules as the scalar `Integer` field.
|
|
474
|
+
- **`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
|
+
- **`TextArray` does not support `:contains`**: it backs a jsonb column where SQL `LIKE` doesn't apply. Use `:any_eq` for "array contains element".
|
|
476
|
+
- **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`.
|
|
478
|
+
|
|
479
|
+
## Database Support
|
|
480
|
+
|
|
481
|
+
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
|
+
|
|
483
|
+
## Schema
|
|
484
|
+
|
|
485
|
+
The gem creates four tables:
|
|
486
|
+
|
|
487
|
+
- `typed_eav_fields` - field definitions (STI, one row per field per entity type)
|
|
488
|
+
- `typed_eav_values` - values (one row per entity per field, with typed columns)
|
|
489
|
+
- `typed_eav_options` - allowed values for select/multi-select fields
|
|
490
|
+
- `typed_eav_sections` - optional UI grouping
|
|
491
|
+
|
|
492
|
+
## License
|
|
493
|
+
|
|
494
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
|
|
5
|
+
APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
require "rspec/core/rake_task"
|
|
9
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
10
|
+
task default: :spec
|
|
11
|
+
rescue LoadError
|
|
12
|
+
# rspec not available
|
|
13
|
+
end
|