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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +494 -0
  5. data/Rakefile +13 -0
  6. data/app/models/typed_eav/application_record.rb +7 -0
  7. data/app/models/typed_eav/field/base.rb +234 -0
  8. data/app/models/typed_eav/field/boolean.rb +22 -0
  9. data/app/models/typed_eav/field/color.rb +16 -0
  10. data/app/models/typed_eav/field/date.rb +24 -0
  11. data/app/models/typed_eav/field/date_array.rb +34 -0
  12. data/app/models/typed_eav/field/date_time.rb +29 -0
  13. data/app/models/typed_eav/field/decimal.rb +30 -0
  14. data/app/models/typed_eav/field/decimal_array.rb +31 -0
  15. data/app/models/typed_eav/field/email.rb +40 -0
  16. data/app/models/typed_eav/field/integer.rb +30 -0
  17. data/app/models/typed_eav/field/integer_array.rb +68 -0
  18. data/app/models/typed_eav/field/json.rb +26 -0
  19. data/app/models/typed_eav/field/long_text.rb +19 -0
  20. data/app/models/typed_eav/field/multi_select.rb +41 -0
  21. data/app/models/typed_eav/field/select.rb +28 -0
  22. data/app/models/typed_eav/field/text.rb +41 -0
  23. data/app/models/typed_eav/field/text_array.rb +36 -0
  24. data/app/models/typed_eav/field/url.rb +40 -0
  25. data/app/models/typed_eav/option.rb +24 -0
  26. data/app/models/typed_eav/section.rb +25 -0
  27. data/app/models/typed_eav/value.rb +149 -0
  28. data/db/migrate/20260330000000_create_typed_eav_tables.rb +132 -0
  29. data/lib/generators/typed_eav/install/install_generator.rb +28 -0
  30. data/lib/generators/typed_eav/scaffold/scaffold_generator.rb +106 -0
  31. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +45 -0
  32. data/lib/generators/typed_eav/scaffold/templates/controllers/concerns/typed_eav_controller_concern.rb +24 -0
  33. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +231 -0
  34. data/lib/generators/typed_eav/scaffold/templates/helpers/typed_eav_helper.rb +150 -0
  35. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js +64 -0
  36. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js +32 -0
  37. data/lib/generators/typed_eav/scaffold/templates/views/shared/_array_field.html.erb +23 -0
  38. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/edit.html.erb +47 -0
  39. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/finders/_form.html.erb +80 -0
  40. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_boolean.html.erb +12 -0
  41. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_color.html.erb +11 -0
  42. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +57 -0
  43. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date.html.erb +21 -0
  44. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_array.html.erb +16 -0
  45. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_time.html.erb +21 -0
  46. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal.html.erb +21 -0
  47. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal_array.html.erb +16 -0
  48. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_email.html.erb +11 -0
  49. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer.html.erb +21 -0
  50. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer_array.html.erb +16 -0
  51. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_json.html.erb +11 -0
  52. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_long_text.html.erb +21 -0
  53. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_multi_select.html.erb +6 -0
  54. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_select.html.erb +14 -0
  55. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text.html.erb +26 -0
  56. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text_array.html.erb +16 -0
  57. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_url.html.erb +11 -0
  58. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +42 -0
  59. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/new.html.erb +7 -0
  60. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +44 -0
  61. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_boolean.html.erb +10 -0
  62. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_color.html.erb +4 -0
  63. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date.html.erb +6 -0
  64. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_array.html.erb +9 -0
  65. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_time.html.erb +5 -0
  66. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal.html.erb +6 -0
  67. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal_array.html.erb +9 -0
  68. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_email.html.erb +5 -0
  69. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer.html.erb +6 -0
  70. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer_array.html.erb +9 -0
  71. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_json.html.erb +7 -0
  72. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_long_text.html.erb +7 -0
  73. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_multi_select.html.erb +7 -0
  74. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_select.html.erb +7 -0
  75. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text.html.erb +6 -0
  76. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb +9 -0
  77. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb +5 -0
  78. data/lib/typed_eav/column_mapping.rb +64 -0
  79. data/lib/typed_eav/config.rb +91 -0
  80. data/lib/typed_eav/engine.rb +20 -0
  81. data/lib/typed_eav/has_typed_eav.rb +484 -0
  82. data/lib/typed_eav/query_builder.rb +133 -0
  83. data/lib/typed_eav/registry.rb +52 -0
  84. data/lib/typed_eav/version.rb +5 -0
  85. data/lib/typed_eav.rb +86 -0
  86. 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end