typed_eav 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +80 -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 +35 -1
data/lib/typed_eav/config.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "active_support/configurable"
|
|
4
|
-
|
|
5
3
|
module TypedEAV
|
|
6
4
|
# Gem-level configuration for field type registration.
|
|
7
5
|
#
|
|
@@ -12,15 +10,46 @@ module TypedEAV
|
|
|
12
10
|
# Accessible from anywhere via `TypedEAV.config` (which returns this
|
|
13
11
|
# class; class-level `field_types` / `register_field_type` / `field_class_for`
|
|
14
12
|
# / `type_names` methods are defined below).
|
|
13
|
+
#
|
|
14
|
+
# Implementation note: class-level accessors are hand-rolled (plain class
|
|
15
|
+
# instance variables behind reader/writer methods) rather than provided by
|
|
16
|
+
# ActiveSupport::Configurable. Configurable was deprecated without
|
|
17
|
+
# replacement in Rails 8.1 and will be removed in Rails 8.2; rolling our
|
|
18
|
+
# own keeps the public API stable across the migration. The `defined?(@var)`
|
|
19
|
+
# idiom on the readers preserves the "never set vs explicitly nil"
|
|
20
|
+
# distinction that callers rely on (e.g., spec_helper's snapshot/restore
|
|
21
|
+
# hook explicitly assigns `nil` and expects the reader to return `nil`,
|
|
22
|
+
# not silently fall through to a default).
|
|
15
23
|
class Config
|
|
16
|
-
include ActiveSupport::Configurable
|
|
17
|
-
|
|
18
24
|
# Default ambient-scope resolver. Auto-detects `acts_as_tenant` when
|
|
19
25
|
# loaded so AAT users get zero-config behavior. Apps using any other
|
|
20
26
|
# multi-tenancy primitive (Rails `Current` attributes, a subdomain
|
|
21
27
|
# lookup, a thread-local, etc.) override via `TypedEAV.configure`.
|
|
28
|
+
#
|
|
29
|
+
# ## Return-value contract (Phase 1, breaking change from v0.1.x)
|
|
30
|
+
#
|
|
31
|
+
# Returns either `nil` (no resolver / opt-out) or a 2-element Array
|
|
32
|
+
# `[scope, parent_scope]`. The `acts_as_tenant` gem has no
|
|
33
|
+
# `parent_scope` analog, so the parent slot is unconditionally `nil`.
|
|
34
|
+
# When AAT is not loaded we return `nil` (the sentinel: no resolver
|
|
35
|
+
# consulted). When AAT is loaded but `current_tenant` is itself nil
|
|
36
|
+
# we return `[nil, nil]` (the sentinel: AAT consulted, no tenant) —
|
|
37
|
+
# intentionally NOT auto-collapsed to nil, to preserve the distinction
|
|
38
|
+
# between "no resolver" and "resolver returned nothing".
|
|
39
|
+
#
|
|
40
|
+
# ## Migration note for v0.1.x custom resolvers
|
|
41
|
+
#
|
|
42
|
+
# Custom resolver lambdas configured via `Config.scope_resolver = ->{ ... }`
|
|
43
|
+
# MUST be updated to return a 2-element Array `[scope, parent_scope]`
|
|
44
|
+
# (or `nil`). A bare-scalar return — the v0.1.x shape — raises
|
|
45
|
+
# `ArgumentError` from `TypedEAV.current_scope`. The shim alternative
|
|
46
|
+
# (auto-coerce scalar to `[scalar, nil]`) was rejected during Phase 1
|
|
47
|
+
# design; we want the breaking change to be loud, not silent. See the
|
|
48
|
+
# CHANGELOG and README migration section for the upgrade pattern.
|
|
22
49
|
DEFAULT_SCOPE_RESOLVER = lambda {
|
|
23
|
-
|
|
50
|
+
next nil unless defined?(::ActsAsTenant)
|
|
51
|
+
|
|
52
|
+
[::ActsAsTenant.current_tenant, nil]
|
|
24
53
|
}
|
|
25
54
|
|
|
26
55
|
# Map of type names to their STI class names.
|
|
@@ -31,37 +60,182 @@ module TypedEAV
|
|
|
31
60
|
integer: "TypedEAV::Field::Integer",
|
|
32
61
|
decimal: "TypedEAV::Field::Decimal",
|
|
33
62
|
boolean: "TypedEAV::Field::Boolean",
|
|
63
|
+
currency: "TypedEAV::Field::Currency",
|
|
34
64
|
date: "TypedEAV::Field::Date",
|
|
35
65
|
date_time: "TypedEAV::Field::DateTime",
|
|
36
66
|
select: "TypedEAV::Field::Select",
|
|
37
67
|
multi_select: "TypedEAV::Field::MultiSelect",
|
|
68
|
+
percentage: "TypedEAV::Field::Percentage",
|
|
69
|
+
reference: "TypedEAV::Field::Reference",
|
|
38
70
|
integer_array: "TypedEAV::Field::IntegerArray",
|
|
39
71
|
decimal_array: "TypedEAV::Field::DecimalArray",
|
|
40
72
|
text_array: "TypedEAV::Field::TextArray",
|
|
41
73
|
date_array: "TypedEAV::Field::DateArray",
|
|
42
74
|
email: "TypedEAV::Field::Email",
|
|
75
|
+
file: "TypedEAV::Field::File",
|
|
76
|
+
image: "TypedEAV::Field::Image",
|
|
43
77
|
url: "TypedEAV::Field::Url",
|
|
44
78
|
color: "TypedEAV::Field::Color",
|
|
45
79
|
json: "TypedEAV::Field::Json",
|
|
46
80
|
}.freeze
|
|
47
81
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
82
|
+
class << self
|
|
83
|
+
# Mutable registry of type_name => class_name pairs. Seeded from
|
|
84
|
+
# BUILTIN_FIELD_TYPES on first access; extended via register_field_type.
|
|
85
|
+
def field_types
|
|
86
|
+
@field_types ||= BUILTIN_FIELD_TYPES.dup
|
|
87
|
+
end
|
|
88
|
+
attr_writer :field_types # rubocop:disable Style/AccessorGrouping
|
|
89
|
+
|
|
90
|
+
# Callable returning the ambient scope (partition key) for class-level
|
|
91
|
+
# queries. Invoked by `TypedEAV.current_scope` when no explicit
|
|
92
|
+
# `scope:` kwarg is passed and no `with_scope` block is active.
|
|
93
|
+
#
|
|
94
|
+
# ## Resolver contract (strict — Phase 1 breaking change)
|
|
95
|
+
#
|
|
96
|
+
# The resolver MUST return either:
|
|
97
|
+
# - `nil` — opt out / no scope to resolve
|
|
98
|
+
# - `[scope, parent_scope]` 2-Array — both elements may be `nil`
|
|
99
|
+
#
|
|
100
|
+
# Any other shape — most importantly a bare scalar (the v0.1.x shape) —
|
|
101
|
+
# raises `ArgumentError` in `TypedEAV.current_scope`. There is no
|
|
102
|
+
# auto-coercion. `parent_scope` non-nil + `scope` nil (orphan parent)
|
|
103
|
+
# is rejected by model-level validators (plans 03 / 04), NOT here —
|
|
104
|
+
# this layer is a contract surface, not a validation surface.
|
|
105
|
+
#
|
|
106
|
+
# Note: `TypedEAV.with_scope(value)` is a DIFFERENT surface — its block
|
|
107
|
+
# API is BC-permissive and accepts a scalar. The resolver-callable
|
|
108
|
+
# contract is strict; the `with_scope` block contract is not. Both
|
|
109
|
+
# surfaces, two contracts.
|
|
110
|
+
def scope_resolver
|
|
111
|
+
defined?(@scope_resolver) ? @scope_resolver : DEFAULT_SCOPE_RESOLVER
|
|
112
|
+
end
|
|
113
|
+
attr_writer :scope_resolver # rubocop:disable Style/AccessorGrouping
|
|
114
|
+
|
|
115
|
+
# When true, class-level queries on a model that declared
|
|
116
|
+
# `has_typed_eav scope_method: ...` raise `TypedEAV::ScopeRequired`
|
|
117
|
+
# if no scope can be resolved (explicit arg, active `with_scope` block,
|
|
118
|
+
# or configured resolver all returned nil). Bypass per-call via
|
|
119
|
+
# `TypedEAV.unscoped { ... }`.
|
|
120
|
+
def require_scope
|
|
121
|
+
defined?(@require_scope) ? @require_scope : true
|
|
122
|
+
end
|
|
123
|
+
attr_writer :require_scope # rubocop:disable Style/AccessorGrouping
|
|
124
|
+
|
|
125
|
+
# Master kill-switch for Phase 04 versioning. When false (default), the
|
|
126
|
+
# Phase 04 internal subscriber is NOT registered with EventDispatcher
|
|
127
|
+
# at engine boot — zero overhead for apps that don't use versioning.
|
|
128
|
+
# When true, the subscriber registers but only writes a version row
|
|
129
|
+
# when value.entity_type belongs to a host model that opted in via
|
|
130
|
+
# `has_typed_eav versioned: true` (per-entity opt-in flows through
|
|
131
|
+
# Registry; both layers land in plan 04-02).
|
|
132
|
+
#
|
|
133
|
+
# Decoupling the master switch from the per-entity decision: disabling
|
|
134
|
+
# for all is one toggle here; enabling for some is a per-host decision
|
|
135
|
+
# in `has_typed_eav`. Apps that want to A/B-test versioning across
|
|
136
|
+
# environments toggle this single flag.
|
|
137
|
+
#
|
|
138
|
+
# Default false because the schema migration only matters for apps that
|
|
139
|
+
# opt in. A v0.1.x deployment that pulls in Phase 04 without changing
|
|
140
|
+
# any config or model declarations sees no behavior change — the
|
|
141
|
+
# subscriber doesn't register, no version rows are written, no perf
|
|
142
|
+
# impact at all. The migration is still copied (idempotent), but the
|
|
143
|
+
# table sits empty.
|
|
144
|
+
def versioning
|
|
145
|
+
defined?(@versioning) ? @versioning : false
|
|
146
|
+
end
|
|
147
|
+
attr_writer :versioning # rubocop:disable Style/AccessorGrouping
|
|
148
|
+
|
|
149
|
+
# Permissive actor resolver. Mirrors the `scope_resolver` callable
|
|
150
|
+
# shape (lib/typed_eav.rb:94: `Config.scope_resolver&.call`) but the
|
|
151
|
+
# return contract is permissive: any value the app chooses (AR object,
|
|
152
|
+
# integer, string, nil) is acceptable, and nil is the documented
|
|
153
|
+
# fail-permissive sentinel.
|
|
154
|
+
#
|
|
155
|
+
# Called from TypedEAV::Versioning::Subscriber (plan 04-02) once per
|
|
156
|
+
# version row write: `actor = TypedEAV.config.actor_resolver&.call`.
|
|
157
|
+
# The return is coerced via `normalize_one`-style String coercion
|
|
158
|
+
# (gem's existing pattern at lib/typed_eav.rb:239-243) before storage
|
|
159
|
+
# in the typed_eav_value_versions.changed_by column. nil flows through
|
|
160
|
+
# as nil — the column is nullable (db/migrate/20260505000000).
|
|
161
|
+
#
|
|
162
|
+
# Why permissive (vs. scope_resolver's strict return contract):
|
|
163
|
+
# missing scope is a tenant-isolation hazard (catastrophic, fail-
|
|
164
|
+
# closed). Missing actor is a degraded audit log (recoverable,
|
|
165
|
+
# sometimes legitimate — system writes, migrations, console).
|
|
166
|
+
# Forcing every Versioned write to have an actor would reject every
|
|
167
|
+
# console save, every migration backfill, every job that didn't set
|
|
168
|
+
# `with_context(actor: ...)` — hostile defaults for a gem.
|
|
169
|
+
# 04-CONTEXT.md §"actor_resolver returning nil" locks the permissive
|
|
170
|
+
# contract; apps that need strict enforcement do it inside their own
|
|
171
|
+
# resolver lambda (`-> { Current.user || raise SomeAppError }`).
|
|
172
|
+
#
|
|
173
|
+
# Default nil (no resolver) means every version row's changed_by is
|
|
174
|
+
# nil. Apps wire this up by setting `c.actor_resolver = -> { ... }`
|
|
175
|
+
# in an initializer alongside `c.versioning = true`.
|
|
176
|
+
def actor_resolver
|
|
177
|
+
defined?(@actor_resolver) ? @actor_resolver : nil
|
|
178
|
+
end
|
|
179
|
+
attr_writer :actor_resolver # rubocop:disable Style/AccessorGrouping
|
|
180
|
+
|
|
181
|
+
# Public single-proc slot for value-change events.
|
|
182
|
+
# Signature: ->(value, change_type, context) { ... }
|
|
183
|
+
# - value: TypedEAV::Value (the just-committed row)
|
|
184
|
+
# - change_type: :create | :update | :destroy
|
|
185
|
+
# - context: Hash (TypedEAV.current_context — frozen)
|
|
186
|
+
#
|
|
187
|
+
# Errors raised inside this proc are rescued by EventDispatcher and
|
|
188
|
+
# logged via Rails.logger.error — they do NOT propagate to the
|
|
189
|
+
# user's save call (the row is already committed). Internal subscribers
|
|
190
|
+
# (Phase 04 versioning, Phase 07 matview) fire BEFORE this proc and
|
|
191
|
+
# their errors DO propagate. See 03-CONTEXT.md §User-callback error policy.
|
|
192
|
+
#
|
|
193
|
+
# Reassignment after gem initialization does NOT disable internal
|
|
194
|
+
# subscribers — those live on EventDispatcher.value_change_internals,
|
|
195
|
+
# not here.
|
|
196
|
+
attr_accessor :on_value_change
|
|
51
197
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
198
|
+
# Public single-proc slot for field-change events.
|
|
199
|
+
# Signature: ->(field, change_type) { ... }
|
|
200
|
+
# - field: TypedEAV::Field::Base (or subclass)
|
|
201
|
+
# - change_type: :create | :update | :destroy | :rename
|
|
202
|
+
#
|
|
203
|
+
# Note: TWO args, no context — asymmetric vs on_value_change by design.
|
|
204
|
+
# Field changes are CRUD-on-config (admin operations on field
|
|
205
|
+
# definitions), not per-entity user actions, so thread context is
|
|
206
|
+
# less relevant. The asymmetry is locked at 03-CONTEXT.md §Phase Boundary.
|
|
207
|
+
#
|
|
208
|
+
# :rename fires when `name` is among Field#saved_changes, even
|
|
209
|
+
# combined with other attr changes (sort_order, options, etc.) —
|
|
210
|
+
# Phase 07 matview needs the rename signal to regenerate column names
|
|
211
|
+
# even when the rename was bundled with other edits.
|
|
212
|
+
attr_accessor :on_field_change
|
|
56
213
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
214
|
+
# Phase 05 hook: fires from after_commit on TypedEAV::Value when a
|
|
215
|
+
# Field::Image-typed Value gains (or replaces) an attachment. Receives
|
|
216
|
+
# `(value, blob)`. Default nil — no-op when not configured.
|
|
217
|
+
#
|
|
218
|
+
# Hook ordering: fires AFTER versioning (Phase 04) and AFTER
|
|
219
|
+
# on_value_change (Phase 03). The hook is informational ("an image
|
|
220
|
+
# was attached"), not mutational; running it last avoids polluting
|
|
221
|
+
# earlier hooks' snapshots / context with attachment-derived state.
|
|
222
|
+
#
|
|
223
|
+
# Active Storage soft-detect (Gating Decision 1, Phase 05): when
|
|
224
|
+
# Active Storage is not loaded at engine boot, the after_commit
|
|
225
|
+
# dispatcher on TypedEAV::Value short-circuits via the
|
|
226
|
+
# `defined?(::ActiveStorage::Blob)` guard — this accessor exists
|
|
227
|
+
# regardless (set/get is a no-op if no dispatcher fires). Mirrors
|
|
228
|
+
# the on_value_change / on_field_change idiom (plain attr_accessor
|
|
229
|
+
# rather than the hand-rolled `defined?(@var)` reader because the
|
|
230
|
+
# hook contract is "nil means unset"; there is no "explicitly nil
|
|
231
|
+
# vs never set" distinction this hook needs to surface).
|
|
232
|
+
#
|
|
233
|
+
# File-attached has no parallel hook in Phase 05 — the on_image_attached
|
|
234
|
+
# name is image-specific by ROADMAP design. Apps that want a generic
|
|
235
|
+
# file-attached signal use on_value_change (Phase 03) or subscribe to
|
|
236
|
+
# ActiveSupport::Notifications directly.
|
|
237
|
+
attr_accessor :on_image_attached
|
|
63
238
|
|
|
64
|
-
class << self
|
|
65
239
|
# Register a custom field type.
|
|
66
240
|
def register_field_type(name, class_name)
|
|
67
241
|
field_types[name.to_sym] = class_name
|
|
@@ -85,6 +259,28 @@ module TypedEAV
|
|
|
85
259
|
self.field_types = BUILTIN_FIELD_TYPES.dup
|
|
86
260
|
self.scope_resolver = DEFAULT_SCOPE_RESOLVER
|
|
87
261
|
self.require_scope = true
|
|
262
|
+
# Phase 04 versioning master switch + actor resolver. Reset to defaults
|
|
263
|
+
# (false / nil) so test isolation matches `Config.on_value_change` / etc.
|
|
264
|
+
# Internal subscribers (TypedEAV::Versioning::Subscriber, registered
|
|
265
|
+
# at engine load by plan 04-02) are deliberately NOT cleared here —
|
|
266
|
+
# they live on EventDispatcher.value_change_internals and survive
|
|
267
|
+
# Config.reset! by design (the snapshot/restore split is locked at
|
|
268
|
+
# 03-CONTEXT.md §Reset split). Test teardown that needs to clear
|
|
269
|
+
# subscribers too calls EventDispatcher.reset!.
|
|
270
|
+
self.versioning = false
|
|
271
|
+
self.actor_resolver = nil
|
|
272
|
+
# Test isolation: scoping_spec/field_spec/etc. call Config.reset! in
|
|
273
|
+
# `after` hooks — this ensures user procs set in earlier tests don't
|
|
274
|
+
# leak across examples. Internal subscribers
|
|
275
|
+
# (EventDispatcher.value_change_internals/field_change_internals) are
|
|
276
|
+
# deliberately NOT reset here — they're populated at engine load by
|
|
277
|
+
# Phase 04+ and must persist across Config.reset!. Test teardown
|
|
278
|
+
# that needs to clear EVERYTHING calls EventDispatcher.reset! too.
|
|
279
|
+
self.on_value_change = nil
|
|
280
|
+
self.on_field_change = nil
|
|
281
|
+
# Phase 05 image-attached hook (parallel to on_value_change /
|
|
282
|
+
# on_field_change reset for test isolation).
|
|
283
|
+
self.on_image_attached = nil
|
|
88
284
|
end
|
|
89
285
|
end
|
|
90
286
|
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module TypedEAV
|
|
6
|
+
# Pure stateless CSV-to-attributes transform.
|
|
7
|
+
#
|
|
8
|
+
# `TypedEAV::CSVMapper.row_to_attributes(row, mapping, fields_by_name: nil)`
|
|
9
|
+
# turns a single CSV row (`CSV::Row` for header-mapped files, or a plain
|
|
10
|
+
# `Array` for index-mapped headerless files) into a `Result` value object
|
|
11
|
+
# with `.attributes`, `.errors`, and `.success?` / `.failure?` predicates.
|
|
12
|
+
# Never raises on per-row content errors — cast failures land in `errors`,
|
|
13
|
+
# NOT in exceptions. The only ArgumentError path is mapping-shape
|
|
14
|
+
# validation, which fires before any row processing.
|
|
15
|
+
#
|
|
16
|
+
# ## Operating modes
|
|
17
|
+
#
|
|
18
|
+
# The 2-arg public form `row_to_attributes(row, mapping)` is the
|
|
19
|
+
# **passthrough mode**: raw cell values flow through unchanged keyed by
|
|
20
|
+
# the mapped field name. No coercion is attempted, no errors are possible.
|
|
21
|
+
# This honors the public 2-arg surface in CONTEXT line 13 + ROADMAP §Phase
|
|
22
|
+
# 6 success criterion exactly. Use this when the caller only needs CSV
|
|
23
|
+
# mapping (header → field-name) without typed coercion — e.g., when
|
|
24
|
+
# building a preview before the host record's partition is known.
|
|
25
|
+
#
|
|
26
|
+
# The 3-arg form `row_to_attributes(row, mapping, fields_by_name:
|
|
27
|
+
# defs_by_name)` is the **typed mode**: per-cell coercion runs through
|
|
28
|
+
# `field.cast(raw)` (the existing tuple contract documented on
|
|
29
|
+
# `TypedEAV::Field::Base#cast`). Cast failures (`invalid? == true`) land
|
|
30
|
+
# in `Result#errors` keyed by the field name, with the AR-symmetric
|
|
31
|
+
# message `"is invalid"`. Empty cells (nil / empty string) cast to nil
|
|
32
|
+
# per the `field.cast` contract and produce `attributes[name] = nil` with
|
|
33
|
+
# NO error. The caller is expected to pass the result of
|
|
34
|
+
# `record.class.typed_eav_definitions(scope:, parent_scope:).index_by(&:name)`
|
|
35
|
+
# (or equivalent) — the mapper has no record context and does not resolve
|
|
36
|
+
# fields itself.
|
|
37
|
+
#
|
|
38
|
+
# ## Mapping shape
|
|
39
|
+
#
|
|
40
|
+
# Single Hash. Keys are EITHER all `String` (CSV header names) OR all
|
|
41
|
+
# `Integer` (column indexes for headerless files). Mixed-key mappings
|
|
42
|
+
# raise `ArgumentError` immediately, before any row is touched, with a
|
|
43
|
+
# remediation message that tells the caller how to fix it.
|
|
44
|
+
#
|
|
45
|
+
# Mapping VALUES are field names — accepted as Symbol or String; the
|
|
46
|
+
# mapper coerces to String before lookup in `fields_by_name`. This
|
|
47
|
+
# matches the codebase convention where `field.name` is always a String.
|
|
48
|
+
#
|
|
49
|
+
# ## Unknown field in mapping (typed mode)
|
|
50
|
+
#
|
|
51
|
+
# When a mapping value (e.g. `:foo`) does NOT appear in `fields_by_name`,
|
|
52
|
+
# the cell is silently SKIPPED — it does NOT produce an error and does
|
|
53
|
+
# NOT appear in `Result#attributes`. Rationale: the mapper is a pure
|
|
54
|
+
# transform and has no record context. Mapping misconfiguration is a
|
|
55
|
+
# caller concern; callers that want to detect it can compare
|
|
56
|
+
# `result.attributes.keys` against the expected set. In passthrough mode
|
|
57
|
+
# there is no `fields_by_name` to look up against, so every mapped cell
|
|
58
|
+
# flows through unconditionally.
|
|
59
|
+
#
|
|
60
|
+
# ## Foundational principle
|
|
61
|
+
#
|
|
62
|
+
# NO HARDCODED ATTRIBUTE REFERENCES. The mapper resolves field metadata
|
|
63
|
+
# via the `fields_by_name:` keyword argument supplied by the caller —
|
|
64
|
+
# the mapper itself never inspects record attributes or partition state.
|
|
65
|
+
# Every field touch goes through `field.cast(raw)` which dispatches via
|
|
66
|
+
# the existing per-type cast contract.
|
|
67
|
+
module CSVMapper
|
|
68
|
+
# Plain value object — NOT an ActiveRecord model. No callbacks, no
|
|
69
|
+
# validations, no DB interaction. Two frozen Hashes; `success?` is just
|
|
70
|
+
# `errors.empty?`. Callers that need to combine multiple row Results
|
|
71
|
+
# into a batch view do so by composing the immutable Hashes in their
|
|
72
|
+
# own code (e.g., `results.flat_map(&:errors).reduce({}, :merge)`); the
|
|
73
|
+
# mapper does not provide a "merge" helper in v0.6.0.
|
|
74
|
+
class Result
|
|
75
|
+
attr_reader :attributes, :errors
|
|
76
|
+
|
|
77
|
+
def initialize(attributes:, errors:)
|
|
78
|
+
@attributes = attributes.freeze
|
|
79
|
+
@errors = errors.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def success?
|
|
83
|
+
@errors.empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def failure?
|
|
87
|
+
!success?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class << self
|
|
92
|
+
# Transform a single row into a `Result`. See module-level docs for
|
|
93
|
+
# the full contract. Returns a `Result`; only raises on mapping-shape
|
|
94
|
+
# errors (mixed String + Integer keys).
|
|
95
|
+
def row_to_attributes(row, mapping, fields_by_name: nil)
|
|
96
|
+
validate_mapping_keys!(mapping)
|
|
97
|
+
|
|
98
|
+
attributes = {}
|
|
99
|
+
errors = {}
|
|
100
|
+
|
|
101
|
+
mapping.each do |source_key, raw_field_name|
|
|
102
|
+
# Unified cell read: both `CSV::Row#[String]` and `Array#[Integer]`
|
|
103
|
+
# work via `[]` — homogeneous key validation above ensures
|
|
104
|
+
# `source_key` matches the row representation (header name vs
|
|
105
|
+
# index).
|
|
106
|
+
raw_cell = row[source_key]
|
|
107
|
+
|
|
108
|
+
# Codebase convention: field names are always Strings on the AR
|
|
109
|
+
# side. Mapping values may be Symbol or String — coerce here so
|
|
110
|
+
# the lookup against `fields_by_name` and the keys in
|
|
111
|
+
# `attributes` / `errors` are consistent regardless of caller
|
|
112
|
+
# input style.
|
|
113
|
+
name = raw_field_name.to_s
|
|
114
|
+
|
|
115
|
+
if fields_by_name.nil?
|
|
116
|
+
# Passthrough mode — no coercion, no errors possible. Honors
|
|
117
|
+
# the 2-arg public surface in CONTEXT line 13 + ROADMAP §Phase
|
|
118
|
+
# 6. Cell flows through unchanged.
|
|
119
|
+
attributes[name] = raw_cell
|
|
120
|
+
else
|
|
121
|
+
# Typed mode — silently skip unknown fields (see module docs).
|
|
122
|
+
field = fields_by_name[name]
|
|
123
|
+
next if field.nil?
|
|
124
|
+
|
|
125
|
+
casted, invalid = field.cast(raw_cell)
|
|
126
|
+
if invalid
|
|
127
|
+
# AR-symmetric message; matches `errors_by_record` in the
|
|
128
|
+
# bulk-write surface and `errors.add(:value, :invalid)` in
|
|
129
|
+
# `Value#validate_value`. Plain Hash with String keys per
|
|
130
|
+
# RESEARCH §Open-Question Resolutions §errors_hash shape.
|
|
131
|
+
(errors[name] ||= []) << "is invalid"
|
|
132
|
+
else
|
|
133
|
+
attributes[name] = casted
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
Result.new(attributes: attributes, errors: errors)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# Mapping-shape validation: keys must be all-String OR all-Integer.
|
|
144
|
+
# Mixed keys raise immediately, BEFORE any row processing — fail
|
|
145
|
+
# fast on configuration errors so the caller catches them on the
|
|
146
|
+
# first invocation rather than silently producing partial Results.
|
|
147
|
+
def validate_mapping_keys!(mapping)
|
|
148
|
+
key_classes = mapping.keys.map(&:class).uniq
|
|
149
|
+
return if key_classes == [String] || key_classes == [Integer] || key_classes.empty?
|
|
150
|
+
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
"CSVMapper mapping must use either all String keys (CSV headers) " \
|
|
153
|
+
"or all Integer keys (column indexes), not both. " \
|
|
154
|
+
"Got: #{mapping.inspect}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# Storage contract for Field::Currency's two-cell value shape.
|
|
5
|
+
class CurrencyStorageContract < FieldStorageContract
|
|
6
|
+
VALUE_COLUMNS = %i[decimal_value string_value].freeze
|
|
7
|
+
AMOUNT_COLUMN = :decimal_value
|
|
8
|
+
CURRENCY_COLUMN = :string_value
|
|
9
|
+
|
|
10
|
+
def self.value_columns
|
|
11
|
+
VALUE_COLUMNS
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.query_column(operator)
|
|
15
|
+
operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
delegate :value_columns, :query_column, to: :class
|
|
19
|
+
|
|
20
|
+
def read(value_record)
|
|
21
|
+
amount = value_record[AMOUNT_COLUMN]
|
|
22
|
+
currency = value_record[CURRENCY_COLUMN]
|
|
23
|
+
return nil if amount.nil? && currency.nil?
|
|
24
|
+
|
|
25
|
+
{ amount: amount, currency: currency }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(value_record, casted)
|
|
29
|
+
if casted.nil?
|
|
30
|
+
value_record[AMOUNT_COLUMN] = nil
|
|
31
|
+
value_record[CURRENCY_COLUMN] = nil
|
|
32
|
+
else
|
|
33
|
+
value_record[AMOUNT_COLUMN] = casted[:amount]
|
|
34
|
+
value_record[CURRENCY_COLUMN] = casted[:currency]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def apply_default(value_record)
|
|
39
|
+
default = field.default_value
|
|
40
|
+
return unless default.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"]
|
|
43
|
+
value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/typed_eav/engine.rb
CHANGED
|
@@ -8,6 +8,13 @@ module TypedEAV
|
|
|
8
8
|
require_relative "column_mapping"
|
|
9
9
|
require_relative "config"
|
|
10
10
|
require_relative "registry"
|
|
11
|
+
# Eager-loaded (not autoloaded) — Phase 04 versioning will register on
|
|
12
|
+
# EventDispatcher at engine boot, before any model reference triggers
|
|
13
|
+
# autoload. Without this require_relative, Phase 04's engine-time
|
|
14
|
+
# `register_internal_value_change` call would const-resolve the module
|
|
15
|
+
# for the first time and run a fresh `@value_change_internals = []`
|
|
16
|
+
# AFTER versioning had already pushed onto a different instance.
|
|
17
|
+
require_relative "event_dispatcher"
|
|
11
18
|
end
|
|
12
19
|
|
|
13
20
|
# Make `has_typed_eav` available on all ActiveRecord models
|
|
@@ -16,5 +23,115 @@ module TypedEAV
|
|
|
16
23
|
include TypedEAV::HasTypedEAV
|
|
17
24
|
end
|
|
18
25
|
end
|
|
26
|
+
|
|
27
|
+
# Phase 04 versioning subscriber registration.
|
|
28
|
+
#
|
|
29
|
+
# CONDITIONAL on TypedEAV.config.versioning. When false (the default
|
|
30
|
+
# for apps that don't enable versioning), no subscriber is registered:
|
|
31
|
+
# zero callable in EventDispatcher.value_change_internals, zero per-write
|
|
32
|
+
# dispatch overhead, zero config reads on the hot path. This is the
|
|
33
|
+
# locked CONTEXT contract — line 17 says "zero overhead for apps that
|
|
34
|
+
# don't use versioning", which means literally no callable, not "callable
|
|
35
|
+
# that early-returns".
|
|
36
|
+
#
|
|
37
|
+
# We use `config.after_initialize` rather than a Rails `initializer` block
|
|
38
|
+
# because we need to consult host-set config values. The host's
|
|
39
|
+
# `config/initializers/typed_eav.rb` runs AFTER all engine initializers
|
|
40
|
+
# but BEFORE `config.after_initialize`. By the time this block fires,
|
|
41
|
+
# `TypedEAV.config.versioning` reflects the host's chosen value (or the
|
|
42
|
+
# default `false` if the host never touched it).
|
|
43
|
+
#
|
|
44
|
+
# Trade-off (documented in 04-02-PLAN §Plan-time decisions §6): apps
|
|
45
|
+
# that toggle `c.versioning = true` at runtime AFTER `after_initialize`
|
|
46
|
+
# has fired (e.g., a Rails console session that monkey-patches Config,
|
|
47
|
+
# or a feature-flag flip mid-process) will NOT get versioning until
|
|
48
|
+
# process restart. Runtime toggle is not a documented use case — adding
|
|
49
|
+
# a register/deregister API is out of scope for Phase 04. The Risk §1
|
|
50
|
+
# late-toggle concern from RESEARCH is acceptably narrowed by this
|
|
51
|
+
# trade-off.
|
|
52
|
+
#
|
|
53
|
+
# Slot 0 ordering: Phase 07 (future matview) will register its
|
|
54
|
+
# subscriber via its own `config.after_initialize` block declared LATER
|
|
55
|
+
# in this same engine file. Rails runs `after_initialize` blocks in
|
|
56
|
+
# declaration order within a single Engine class, so versioning's block
|
|
57
|
+
# fires first → slot 0. The regression spec (plan 04-03 P03) is the
|
|
58
|
+
# ongoing guard.
|
|
59
|
+
#
|
|
60
|
+
# Why a one-line callable to a class method (not inline registration):
|
|
61
|
+
# `TypedEAV::Versioning.register_if_enabled` is the testable seam. The
|
|
62
|
+
# slot-0 regression spec (plan 04-03 P03) and the zero-overhead
|
|
63
|
+
# verification spec (this plan, subscriber_spec) cannot reboot the Rails
|
|
64
|
+
# process inside RSpec — but they CAN call the helper directly against
|
|
65
|
+
# a fresh internals array to exercise both branches (versioning on/off)
|
|
66
|
+
# in-process. Inlining the `if` here would force tests to either reboot
|
|
67
|
+
# the engine or use brittle private-block extraction.
|
|
68
|
+
config.after_initialize do
|
|
69
|
+
TypedEAV::Versioning.register_if_enabled
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Phase 05 Active Storage soft-detect (Gating Decision 1).
|
|
73
|
+
#
|
|
74
|
+
# Mirrors the acts_as_tenant precedent (Config::DEFAULT_SCOPE_RESOLVER
|
|
75
|
+
# in lib/typed_eav/config.rb lines 49-53): the gem detects without
|
|
76
|
+
# requiring. When ActiveStorage::Blob is not defined at this point in
|
|
77
|
+
# boot, has_one_attached is NOT registered on TypedEAV::Value — apps
|
|
78
|
+
# that don't use Image/File field types pay zero overhead, AND the
|
|
79
|
+
# gemspec stays free of an activestorage hard-dependency.
|
|
80
|
+
#
|
|
81
|
+
# When AS IS loaded (Rails 7.1+ with the rails meta-gem, or an
|
|
82
|
+
# explicit `gem 'activestorage'` line), TypedEAV::Value gains a
|
|
83
|
+
# single :attachment has_one_attached association that covers BOTH
|
|
84
|
+
# Field::Image and Field::File typed Values. The Image vs File
|
|
85
|
+
# distinction at runtime is `value.field.is_a?(Field::Image)` (used
|
|
86
|
+
# by the on_image_attached dispatcher on TypedEAV::Value); the blob's
|
|
87
|
+
# content_type is the source of truth for image-vs-other-file at
|
|
88
|
+
# render time.
|
|
89
|
+
#
|
|
90
|
+
# Why a single shared association (not :image_attachment +
|
|
91
|
+
# :file_attachment): TypedEAV::Value is a monolithic table — every
|
|
92
|
+
# Value row gets every association declared on the class. Two
|
|
93
|
+
# associations would double the AR association overhead on every
|
|
94
|
+
# Value row (Text, Integer, etc.), even when no attachment is in
|
|
95
|
+
# play. RESEARCH §Risk 3 documents this rationale.
|
|
96
|
+
#
|
|
97
|
+
# Second after_initialize block (versioning's is the first): Rails
|
|
98
|
+
# runs after_initialize blocks in declaration order within a single
|
|
99
|
+
# Engine class. Versioning's slot-0 dispatcher position at the
|
|
100
|
+
# EventDispatcher level is preserved (dispatcher slots are an
|
|
101
|
+
# EventDispatcher-internal concern; the engine's after_initialize
|
|
102
|
+
# ordering is independent). Phase 07 matview will append its own
|
|
103
|
+
# block after this one.
|
|
104
|
+
#
|
|
105
|
+
# Why a one-line callable to a class method (testable seam): the
|
|
106
|
+
# active_storage_soft_detect_spec cannot reboot Rails inside RSpec
|
|
107
|
+
# to exercise both branches. By extracting the body into
|
|
108
|
+
# `Engine.register_attachment_associations!`, specs call the helper
|
|
109
|
+
# directly with whatever ::ActiveStorage state they need to test.
|
|
110
|
+
# Pattern matches Phase 04's `Versioning.register_if_enabled`.
|
|
111
|
+
config.after_initialize do
|
|
112
|
+
TypedEAV::Engine.register_attachment_associations!
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Conditionally register the :attachment has_one_attached association
|
|
116
|
+
# on TypedEAV::Value. Idempotent — safe to call multiple times. The
|
|
117
|
+
# idempotency guard (`@attachment_registered`) prevents double-
|
|
118
|
+
# registration when specs invoke the seam in addition to the engine
|
|
119
|
+
# boot path. Without the guard, AR's has_one_attached macro would
|
|
120
|
+
# redefine the association methods (technically harmless but
|
|
121
|
+
# generates RuntimeError noise on duplicate declaration in newer AS
|
|
122
|
+
# versions).
|
|
123
|
+
#
|
|
124
|
+
# Returns truthy on first successful registration, falsy when AS is
|
|
125
|
+
# unloaded or the association is already registered. The return is
|
|
126
|
+
# not part of the public contract — specs that care about the
|
|
127
|
+
# registration outcome inspect TypedEAV::Value.reflect_on_attachment
|
|
128
|
+
# directly.
|
|
129
|
+
def self.register_attachment_associations!
|
|
130
|
+
return false unless defined?(::ActiveStorage::Blob)
|
|
131
|
+
return false if @attachment_registered
|
|
132
|
+
|
|
133
|
+
TypedEAV::Value.has_one_attached :attachment
|
|
134
|
+
@attachment_registered = true
|
|
135
|
+
end
|
|
19
136
|
end
|
|
20
137
|
end
|