typed_eav 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +134 -0
- data/README.md +287 -47
- data/app/models/typed_eav/field/base.rb +28 -159
- data/app/models/typed_eav/field/currency.rb +54 -20
- data/app/models/typed_eav/field/date.rb +16 -1
- data/app/models/typed_eav/field/date_time.rb +16 -1
- data/app/models/typed_eav/field/decimal.rb +9 -1
- data/app/models/typed_eav/field/email.rb +13 -16
- data/app/models/typed_eav/field/integer.rb +6 -1
- data/app/models/typed_eav/field/long_text.rb +17 -1
- data/app/models/typed_eav/field/multi_select.rb +12 -9
- data/app/models/typed_eav/field/optionable.rb +59 -0
- data/app/models/typed_eav/field/percentage.rb +6 -6
- data/app/models/typed_eav/field/range_bounded.rb +71 -0
- data/app/models/typed_eav/field/select.rb +9 -10
- data/app/models/typed_eav/field/text.rb +11 -29
- data/app/models/typed_eav/field/url.rb +14 -16
- data/app/models/typed_eav/field/validated_string.rb +87 -0
- data/app/models/typed_eav/value.rb +9 -9
- data/lib/typed_eav/bulk_read.rb +124 -0
- data/lib/typed_eav/engine.rb +1 -1
- data/lib/typed_eav/entity_query.rb +186 -0
- data/lib/typed_eav/field/typed_storage.rb +205 -0
- data/lib/typed_eav/filter_query.rb +148 -0
- data/lib/typed_eav/has_typed_eav/instance_methods.rb +253 -0
- data/lib/typed_eav/has_typed_eav.rb +29 -793
- data/lib/typed_eav/partition.rb +51 -11
- data/lib/typed_eav/query_builder.rb +6 -7
- data/lib/typed_eav/scope_tuple.rb +116 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioning/subscriber.rb +7 -6
- data/lib/typed_eav.rb +23 -64
- metadata +10 -4
- data/lib/typed_eav/column_mapping.rb +0 -110
- data/lib/typed_eav/currency_storage_contract.rb +0 -46
- data/lib/typed_eav/field_storage_contract.rb +0 -68
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "timeout"
|
|
4
|
-
|
|
5
3
|
module TypedEAV
|
|
6
4
|
module Field
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# and the partition-aware backfill all live here together
|
|
10
|
-
# scope, parent_scope) partition
|
|
11
|
-
#
|
|
5
|
+
# Field::Base is the central STI parent: associations, validations,
|
|
6
|
+
# cascade dispatch, partition-aware ordering helpers, default-value
|
|
7
|
+
# handling, and the partition-aware backfill all live here together
|
|
8
|
+
# because they share the (entity_type, scope, parent_scope) partition
|
|
9
|
+
# contract. Validation helpers that were previously co-located (length /
|
|
10
|
+
# pattern / range / option-inclusion) have moved down to the new family
|
|
11
|
+
# bases (`Field::ValidatedString`, `Field::RangeBounded`,
|
|
12
|
+
# `Field::Optionable`) per ADR-0004; `validate_array_size` stays here
|
|
13
|
+
# because its callers span unrelated families.
|
|
12
14
|
class Base < ApplicationRecord
|
|
13
15
|
self.table_name = "typed_eav_fields"
|
|
14
16
|
|
|
15
|
-
include TypedEAV::
|
|
17
|
+
include TypedEAV::Field::TypedStorage
|
|
16
18
|
|
|
17
19
|
# ── Associations ──
|
|
18
20
|
|
|
@@ -244,84 +246,18 @@ module TypedEAV
|
|
|
244
246
|
[raw, false]
|
|
245
247
|
end
|
|
246
248
|
|
|
247
|
-
# ──
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
# column
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
# across decimal_value + string_value) override these to compose /
|
|
257
|
-
# unpack the logical value across multiple physical columns. The
|
|
258
|
-
# dispatch keeps Value#value, Value#value=, and Value#apply_field_default
|
|
259
|
-
# oblivious to multi-cell — they always go through the field, so adding
|
|
260
|
-
# new multi-cell types in the future requires no Value-side changes.
|
|
261
|
-
#
|
|
262
|
-
# IMPORTANT: read_value, write_value, and apply_default_to are paired.
|
|
263
|
-
# Currency overrides ALL THREE — overriding only one creates an
|
|
264
|
-
# asymmetry where reads see the multi-cell shape but writes / defaults
|
|
265
|
-
# populate only one column (or vice versa).
|
|
266
|
-
|
|
267
|
-
# Returns the logical value for this field as stored on the given
|
|
268
|
-
# Value record. Default reads `value_record[self.class.value_column]`.
|
|
269
|
-
# Override in multi-cell field types to compose a hash from multiple
|
|
270
|
-
# columns (e.g., Field::Currency returns
|
|
271
|
-
# `{amount: r[:decimal_value], currency: r[:string_value]}`).
|
|
272
|
-
#
|
|
273
|
-
# Called from Value#value. The Value#value `return nil unless field`
|
|
274
|
-
# guard runs before this method, so `self` is always set.
|
|
275
|
-
def read_value(value_record)
|
|
276
|
-
value_record[self.class.value_column]
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
# Writes a casted value to the given Value record. Default writes
|
|
280
|
-
# `value_record[self.class.value_column] = casted`. Override in multi-
|
|
281
|
-
# cell types to unpack a composite casted value into multiple columns
|
|
282
|
-
# (e.g., Field::Currency unpacks `{amount: BigDecimal, currency: String}`
|
|
283
|
-
# into decimal_value + string_value).
|
|
284
|
-
#
|
|
285
|
-
# Called from Value#value=. The cast invariant is preserved: `casted`
|
|
286
|
-
# is whatever the field's `cast(raw)` returned as the first element.
|
|
287
|
-
# For single-cell types that's a scalar; for Currency it's a Hash.
|
|
288
|
-
# Without this dispatch, a Currency cast result (a Hash) would be
|
|
289
|
-
# written to a single typed column, raising TypeMismatch at save time.
|
|
290
|
-
def write_value(value_record, casted)
|
|
291
|
-
value_record[self.class.value_column] = casted
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
# Writes this field's configured default to the given Value record.
|
|
295
|
-
# Default writes `value_record[self.class.value_column] = default_value`,
|
|
296
|
-
# bypassing Value#value= to avoid re-casting an already-cast default
|
|
297
|
-
# (default_value is cast at field save time via validate_default_value).
|
|
298
|
-
# Override in multi-cell types to populate multiple columns from a
|
|
299
|
-
# composite default (e.g., Field::Currency unpacks `default_value`'s
|
|
300
|
-
# `{amount: ..., currency: ...}` hash into decimal_value + string_value).
|
|
301
|
-
#
|
|
302
|
-
# Called from Value#apply_field_default in two contexts:
|
|
303
|
-
# 1. Initial value assignment when no `value:` kwarg was passed
|
|
304
|
-
# (UNSET_VALUE sentinel resolution path).
|
|
305
|
-
# 2. Pending-value resolution (apply_pending_value branch where
|
|
306
|
-
# @pending_value was UNSET_VALUE and the field arrived later).
|
|
307
|
-
def apply_default_to(value_record)
|
|
308
|
-
value_record[self.class.value_column] = default_value
|
|
309
|
-
end
|
|
249
|
+
# ── Multi-cell extension points ──
|
|
250
|
+
#
|
|
251
|
+
# The three override-point instance methods (`read_value`,
|
|
252
|
+
# `write_value`, `apply_default`) and the concrete snapshot helpers
|
|
253
|
+
# (`value_changed?`, `before_snapshot`, `after_snapshot`) live in the
|
|
254
|
+
# `Field::TypedStorage` concern included above. Field types that span
|
|
255
|
+
# more than one typed column override all three to compose / unpack the
|
|
256
|
+
# logical value across multiple cells; `Field::Currency` is the
|
|
257
|
+
# canonical example.
|
|
310
258
|
|
|
311
259
|
# ── Introspection ──
|
|
312
260
|
|
|
313
|
-
def self.storage_contract_class(contract_class = nil)
|
|
314
|
-
if contract_class
|
|
315
|
-
@storage_contract_class = contract_class
|
|
316
|
-
else
|
|
317
|
-
@storage_contract_class || TypedEAV::FieldStorageContract
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def storage_contract
|
|
322
|
-
@storage_contract ||= self.class.storage_contract_class.new(self)
|
|
323
|
-
end
|
|
324
|
-
|
|
325
261
|
def field_type_name
|
|
326
262
|
self.class.name.demodulize.underscore
|
|
327
263
|
end
|
|
@@ -442,8 +378,14 @@ module TypedEAV
|
|
|
442
378
|
#
|
|
443
379
|
# Default no-op. Subclasses override to enforce their constraints
|
|
444
380
|
# (length, range, pattern, option inclusion, array size, etc.) and
|
|
445
|
-
# add errors to `record.errors`.
|
|
446
|
-
#
|
|
381
|
+
# add errors to `record.errors`. Family-specific helpers live on the
|
|
382
|
+
# appropriate family base / concern (`ValidatedString` provides
|
|
383
|
+
# `validate_length` / `validate_pattern`; `RangeBounded` provides
|
|
384
|
+
# `validate_range` / `validate_date_range` / `validate_datetime_range`;
|
|
385
|
+
# `Optionable` provides `validate_option_inclusion` /
|
|
386
|
+
# `validate_multi_option_inclusion`). `validate_array_size` stays
|
|
387
|
+
# here because its callers (MultiSelect via Optionable AND
|
|
388
|
+
# IntegerArray directly) don't share a family.
|
|
447
389
|
def validate_typed_value(record, val)
|
|
448
390
|
# no-op by default
|
|
449
391
|
end
|
|
@@ -454,77 +396,6 @@ module TypedEAV
|
|
|
454
396
|
options&.with_indifferent_access || {}
|
|
455
397
|
end
|
|
456
398
|
|
|
457
|
-
def validate_length(record, val)
|
|
458
|
-
opts = options_hash
|
|
459
|
-
str = val.to_s
|
|
460
|
-
if opts[:min_length] && str.length < opts[:min_length].to_i
|
|
461
|
-
record.errors.add(:value, :too_short, count: opts[:min_length])
|
|
462
|
-
end
|
|
463
|
-
return unless opts[:max_length] && str.length > opts[:max_length].to_i
|
|
464
|
-
|
|
465
|
-
record.errors.add(:value, :too_long, count: opts[:max_length])
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
def validate_pattern(record, val)
|
|
469
|
-
opts = options_hash
|
|
470
|
-
pattern = opts[:pattern]
|
|
471
|
-
return if pattern.blank?
|
|
472
|
-
|
|
473
|
-
matched = Timeout.timeout(1) { Regexp.new(pattern).match?(val.to_s) }
|
|
474
|
-
record.errors.add(:value, :invalid) unless matched
|
|
475
|
-
rescue RegexpError
|
|
476
|
-
record.errors.add(:value, "has an invalid pattern configured")
|
|
477
|
-
rescue Timeout::Error
|
|
478
|
-
record.errors.add(:value, "pattern validation timed out")
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
def validate_range(record, val)
|
|
482
|
-
opts = options_hash
|
|
483
|
-
record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min]) if opts[:min] && val < opts[:min].to_d
|
|
484
|
-
return unless opts[:max] && val > opts[:max].to_d
|
|
485
|
-
|
|
486
|
-
record.errors.add(:value, :less_than_or_equal_to, count: opts[:max])
|
|
487
|
-
end
|
|
488
|
-
|
|
489
|
-
def validate_date_range(record, val)
|
|
490
|
-
opts = options_hash
|
|
491
|
-
if opts[:min_date]
|
|
492
|
-
min = ::Date.parse(opts[:min_date])
|
|
493
|
-
record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_date]) if val < min
|
|
494
|
-
end
|
|
495
|
-
if opts[:max_date]
|
|
496
|
-
max = ::Date.parse(opts[:max_date])
|
|
497
|
-
record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_date]) if val > max
|
|
498
|
-
end
|
|
499
|
-
rescue ::Date::Error
|
|
500
|
-
record.errors.add(:base, "field has invalid date configuration")
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
def validate_datetime_range(record, val)
|
|
504
|
-
opts = options_hash
|
|
505
|
-
if opts[:min_datetime]
|
|
506
|
-
min = ::Time.zone.parse(opts[:min_datetime])
|
|
507
|
-
record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_datetime]) if val < min
|
|
508
|
-
end
|
|
509
|
-
if opts[:max_datetime]
|
|
510
|
-
max = ::Time.zone.parse(opts[:max_datetime])
|
|
511
|
-
record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_datetime]) if val > max
|
|
512
|
-
end
|
|
513
|
-
rescue ArgumentError
|
|
514
|
-
record.errors.add(:base, "field has invalid datetime configuration")
|
|
515
|
-
end
|
|
516
|
-
|
|
517
|
-
def validate_option_inclusion(record, val)
|
|
518
|
-
return if allowed_option_values.include?(val&.to_s)
|
|
519
|
-
|
|
520
|
-
record.errors.add(:value, :inclusion)
|
|
521
|
-
end
|
|
522
|
-
|
|
523
|
-
def validate_multi_option_inclusion(record, val)
|
|
524
|
-
invalid = Array(val).map(&:to_s) - allowed_option_values
|
|
525
|
-
record.errors.add(:value, :inclusion) if invalid.any?
|
|
526
|
-
end
|
|
527
|
-
|
|
528
399
|
def validate_array_size(record, val)
|
|
529
400
|
opts = options_hash
|
|
530
401
|
arr = Array(val)
|
|
@@ -579,8 +450,7 @@ module TypedEAV
|
|
|
579
450
|
# the same incoherent state as a literal NULL. Same reasoning for
|
|
580
451
|
# `scope.present?`.
|
|
581
452
|
def validate_parent_scope_invariant
|
|
582
|
-
return if
|
|
583
|
-
return if scope.present?
|
|
453
|
+
return if TypedEAV::ScopeTuple.invariant_satisfied?(scope, parent_scope)
|
|
584
454
|
|
|
585
455
|
errors.add(:parent_scope, "cannot be set when scope is blank")
|
|
586
456
|
end
|
|
@@ -775,6 +645,5 @@ module TypedEAV
|
|
|
775
645
|
TypedEAV::EventDispatcher.dispatch_field_change(self, change_type)
|
|
776
646
|
end
|
|
777
647
|
end
|
|
778
|
-
# rubocop:enable Metrics/ClassLength
|
|
779
648
|
end
|
|
780
649
|
end
|
|
@@ -5,21 +5,21 @@ module TypedEAV
|
|
|
5
5
|
# Two-cell field type: stores `{amount: BigDecimal, currency: String}`
|
|
6
6
|
# across decimal_value (amount) + string_value (currency ISO 4217 code).
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
# - value_columns
|
|
10
|
-
# versioning's snapshot loop
|
|
11
|
-
# _dispatch_value_change_update filter so a change to either cell
|
|
8
|
+
# Multi-cell contract:
|
|
9
|
+
# - `value_columns :decimal_value, :string_value` — both cells propagate
|
|
10
|
+
# through versioning's snapshot loop and the Value
|
|
11
|
+
# `_dispatch_value_change_update` filter so a change to either cell
|
|
12
12
|
# correctly fires the :update event.
|
|
13
|
-
# - operator_column
|
|
14
|
-
#
|
|
15
|
-
# - read_value / write_value /
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# - cast
|
|
20
|
-
# currency dimension is required at write time. Silently
|
|
21
|
-
# to default_currency would invite bugs where users forget
|
|
22
|
-
# currency dimension entirely.
|
|
13
|
+
# - `operator_column` routes `:currency_eq` → `:string_value` and every
|
|
14
|
+
# other supported op → `:decimal_value`. QueryBuilder reads this.
|
|
15
|
+
# - `read_value` / `write_value` / `apply_default` are the three
|
|
16
|
+
# overrides paired with the multi-cell declaration. Without all three,
|
|
17
|
+
# single-cell defaults would write a Hash to decimal_value and raise
|
|
18
|
+
# TypeMismatch at save time.
|
|
19
|
+
# - `cast` requires a Hash input. Bare Numeric/String is invalid —
|
|
20
|
+
# explicit currency dimension is required at write time. Silently
|
|
21
|
+
# defaulting to default_currency would invite bugs where users forget
|
|
22
|
+
# the currency dimension entirely.
|
|
23
23
|
#
|
|
24
24
|
# Operators (explicit narrowing — does NOT inherit string-search ops
|
|
25
25
|
# like :contains/:starts_with from decimal_value's default since those
|
|
@@ -39,9 +39,10 @@ module TypedEAV
|
|
|
39
39
|
# - allowed_currencies: Array<String> of ISO codes. When set,
|
|
40
40
|
# validate_typed_value enforces inclusion.
|
|
41
41
|
class Currency < Base
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
AMOUNT_COLUMN = :decimal_value
|
|
43
|
+
CURRENCY_COLUMN = :string_value
|
|
44
44
|
|
|
45
|
+
value_columns AMOUNT_COLUMN, CURRENCY_COLUMN
|
|
45
46
|
operators(*%i[eq gt lt gteq lteq between currency_eq is_null is_not_null])
|
|
46
47
|
|
|
47
48
|
store_accessor :options, :default_currency, :allowed_currencies
|
|
@@ -49,12 +50,45 @@ module TypedEAV
|
|
|
49
50
|
validates :default_currency, format: { with: /\A[A-Z]{3}\z/ }, allow_nil: true
|
|
50
51
|
validate :allowed_currencies_format
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
# Route `:currency_eq` to the currency-code cell; every other supported
|
|
54
|
+
# operator targets the amount cell. The operator-validation gate in
|
|
55
|
+
# QueryBuilder.filter has already narrowed `operator` to the set
|
|
56
|
+
# declared above by the time this runs.
|
|
57
|
+
def self.operator_column(operator)
|
|
58
|
+
operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN
|
|
54
59
|
end
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
# Compose the logical Hash from the two cells. Returns `nil` only
|
|
62
|
+
# when BOTH cells are nil — a half-populated row still round-trips
|
|
63
|
+
# as the partial Hash so validation can surface the missing dimension.
|
|
64
|
+
def read_value(value_record)
|
|
65
|
+
amount = value_record[AMOUNT_COLUMN]
|
|
66
|
+
currency = value_record[CURRENCY_COLUMN]
|
|
67
|
+
return nil if amount.nil? && currency.nil?
|
|
68
|
+
|
|
69
|
+
{ amount: amount, currency: currency }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Unpack the casted Hash across the two cells. `nil` clears both.
|
|
73
|
+
def write_value(value_record, casted)
|
|
74
|
+
if casted.nil?
|
|
75
|
+
value_record[AMOUNT_COLUMN] = nil
|
|
76
|
+
value_record[CURRENCY_COLUMN] = nil
|
|
77
|
+
else
|
|
78
|
+
value_record[AMOUNT_COLUMN] = casted[:amount]
|
|
79
|
+
value_record[CURRENCY_COLUMN] = casted[:currency]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Populate both cells from the field's configured default. Mirrors
|
|
84
|
+
# `write_value`'s Hash decomposition; tolerates string-keyed defaults
|
|
85
|
+
# for jsonb round-trip (`default_value_meta` stores raw config).
|
|
86
|
+
def apply_default(value_record)
|
|
87
|
+
default = default_value
|
|
88
|
+
return unless default.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"]
|
|
91
|
+
value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"]
|
|
58
92
|
end
|
|
59
93
|
|
|
60
94
|
# Cast Hash input → [{amount: BigDecimal, currency: String}, false]
|
|
@@ -2,11 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module TypedEAV
|
|
4
4
|
module Field
|
|
5
|
-
|
|
5
|
+
# Date-typed field with optional min_date/max_date guards. Declares
|
|
6
|
+
# its own `date_value` storage and `:min_date`/`:max_date`
|
|
7
|
+
# `store_accessor`. `validate_date_range` is inherited from
|
|
8
|
+
# `Field::RangeBounded`.
|
|
9
|
+
#
|
|
10
|
+
# Latent-bug fix (per ADR-0004): the
|
|
11
|
+
# `validates :max_date, comparison: { greater_than_or_equal_to: :min_date }`
|
|
12
|
+
# macro now applies here (previously only Integer/Decimal carried it).
|
|
13
|
+
# A Date field configured with `max_date < min_date` fails at
|
|
14
|
+
# field-save instead of saving silently.
|
|
15
|
+
class Date < RangeBounded
|
|
6
16
|
value_column :date_value
|
|
7
17
|
|
|
8
18
|
store_accessor :options, :min_date, :max_date
|
|
9
19
|
|
|
20
|
+
validates :max_date,
|
|
21
|
+
comparison: { greater_than_or_equal_to: :min_date },
|
|
22
|
+
allow_nil: true,
|
|
23
|
+
if: :min_date
|
|
24
|
+
|
|
10
25
|
def cast(raw)
|
|
11
26
|
return [nil, false] if raw.nil?
|
|
12
27
|
|
|
@@ -2,11 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module TypedEAV
|
|
4
4
|
module Field
|
|
5
|
-
|
|
5
|
+
# DateTime-typed field with optional min_datetime/max_datetime
|
|
6
|
+
# guards. Declares its own `datetime_value` storage and
|
|
7
|
+
# `:min_datetime`/`:max_datetime` `store_accessor`.
|
|
8
|
+
# `validate_datetime_range` is inherited from `Field::RangeBounded`.
|
|
9
|
+
#
|
|
10
|
+
# Latent-bug fix (per ADR-0004): the
|
|
11
|
+
# `validates :max_datetime, comparison: { greater_than_or_equal_to: :min_datetime }`
|
|
12
|
+
# macro now applies here (previously only Integer/Decimal carried
|
|
13
|
+
# it). A DateTime field configured with `max_datetime < min_datetime`
|
|
14
|
+
# fails at field-save instead of saving silently.
|
|
15
|
+
class DateTime < RangeBounded
|
|
6
16
|
value_column :datetime_value
|
|
7
17
|
|
|
8
18
|
store_accessor :options, :min_datetime, :max_datetime
|
|
9
19
|
|
|
20
|
+
validates :max_datetime,
|
|
21
|
+
comparison: { greater_than_or_equal_to: :min_datetime },
|
|
22
|
+
allow_nil: true,
|
|
23
|
+
if: :min_datetime
|
|
24
|
+
|
|
10
25
|
def cast(raw)
|
|
11
26
|
return [nil, false] if raw.nil?
|
|
12
27
|
return [raw, false] if raw.is_a?(::Time)
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module TypedEAV
|
|
4
4
|
module Field
|
|
5
|
-
|
|
5
|
+
# Decimal-typed field with optional min/max guards and optional
|
|
6
|
+
# `precision_scale` rounding. Declares its own `decimal_value`
|
|
7
|
+
# storage and `:min`/`:max`/`:precision_scale` `store_accessor`; the
|
|
8
|
+
# `validate :max, comparison:` macro guards against inverted bounds
|
|
9
|
+
# at field-save. `validate_range` is inherited from
|
|
10
|
+
# `Field::RangeBounded`. `Field::Percentage` extends this class to
|
|
11
|
+
# add the 0..1 invariant (chain depth becomes
|
|
12
|
+
# `Percentage < Decimal < RangeBounded < Base`).
|
|
13
|
+
class Decimal < RangeBounded
|
|
6
14
|
value_column :decimal_value
|
|
7
15
|
|
|
8
16
|
store_accessor :options, :min, :max, :precision_scale
|
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module TypedEAV
|
|
4
4
|
module Field
|
|
5
|
-
|
|
5
|
+
# Email-typed field. Inherits `string_value` storage shape, the
|
|
6
|
+
# `min_length` / `max_length` / `pattern` `store_accessor`, the
|
|
7
|
+
# `max_gte_min_length` guard, and the `validate_pattern_syntax` guard
|
|
8
|
+
# from `Field::ValidatedString`. Adds the `EMAIL_FORMAT` regex and
|
|
9
|
+
# layers the format check onto `validate_typed_value` via `super`.
|
|
10
|
+
#
|
|
11
|
+
# Latent-bug fix (per ADR-0004): `max_gte_min_length` (previously
|
|
12
|
+
# only on Text) now applies here — an Email field configured with
|
|
13
|
+
# `max_length < min_length` fails at field-save.
|
|
14
|
+
class Email < ValidatedString
|
|
15
|
+
# Re-declare value_column for Ruby class-ivar non-inheritance — see
|
|
16
|
+
# comment on Field::Text. STI dispatch is unaffected.
|
|
6
17
|
value_column :string_value
|
|
7
18
|
|
|
8
|
-
store_accessor :options, :min_length, :max_length, :pattern
|
|
9
|
-
validate :validate_pattern_syntax
|
|
10
|
-
|
|
11
19
|
EMAIL_FORMAT = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
|
|
12
20
|
|
|
13
21
|
def cast(raw)
|
|
@@ -21,20 +29,9 @@ module TypedEAV
|
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
def validate_typed_value(record, val)
|
|
24
|
-
|
|
25
|
-
validate_pattern(record, val) if pattern.present?
|
|
32
|
+
super
|
|
26
33
|
record.errors.add(:value, "is not a valid email address") unless email_format_valid?(val)
|
|
27
34
|
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def validate_pattern_syntax
|
|
32
|
-
return if pattern.blank?
|
|
33
|
-
|
|
34
|
-
Regexp.new(pattern)
|
|
35
|
-
rescue RegexpError => e
|
|
36
|
-
errors.add(:pattern, "is invalid: #{e.message}")
|
|
37
|
-
end
|
|
38
35
|
end
|
|
39
36
|
end
|
|
40
37
|
end
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module TypedEAV
|
|
4
4
|
module Field
|
|
5
|
-
|
|
5
|
+
# Integer-typed field with optional min/max guards. Declares its own
|
|
6
|
+
# `integer_value` storage and `:min`/`:max` `store_accessor`; the
|
|
7
|
+
# `validate :max, comparison:` macro guards against inverted bounds
|
|
8
|
+
# at field-save. `validate_range` is inherited from
|
|
9
|
+
# `Field::RangeBounded`.
|
|
10
|
+
class Integer < RangeBounded
|
|
6
11
|
value_column :integer_value
|
|
7
12
|
|
|
8
13
|
store_accessor :options, :min, :max
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module TypedEAV
|
|
4
4
|
module Field
|
|
5
|
+
# Long-text-typed field stored in the `text_value` column. Kept as a
|
|
6
|
+
# direct child of `Field::Base` (per ADR-0004 — array types,
|
|
7
|
+
# LongText, File, Image, Reference all stay direct children; only
|
|
8
|
+
# Text/Email/Url moved into the new `Field::ValidatedString` family).
|
|
9
|
+
# Inlines its own min_length / max_length check rather than joining
|
|
10
|
+
# the ValidatedString family because LongText does NOT carry the
|
|
11
|
+
# `:pattern` option, the `max_gte_min_length` guard, or the
|
|
12
|
+
# `validate_pattern_syntax` guard — the family base would attach
|
|
13
|
+
# validation surface LongText doesn't want.
|
|
5
14
|
class LongText < Base
|
|
6
15
|
value_column :text_value
|
|
7
16
|
|
|
@@ -12,7 +21,14 @@ module TypedEAV
|
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
def validate_typed_value(record, val)
|
|
15
|
-
|
|
24
|
+
opts = options_hash
|
|
25
|
+
str = val.to_s
|
|
26
|
+
if opts[:min_length] && str.length < opts[:min_length].to_i
|
|
27
|
+
record.errors.add(:value, :too_short, count: opts[:min_length])
|
|
28
|
+
end
|
|
29
|
+
return unless opts[:max_length] && str.length > opts[:max_length].to_i
|
|
30
|
+
|
|
31
|
+
record.errors.add(:value, :too_long, count: opts[:max_length])
|
|
16
32
|
end
|
|
17
33
|
end
|
|
18
34
|
end
|
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module TypedEAV
|
|
4
4
|
module Field
|
|
5
|
+
# Multi-choice option-set field. Stores the chosen values as a JSON
|
|
6
|
+
# array in `json_value`. Inherits `optionable? = true`, the
|
|
7
|
+
# public-facing sorted `allowed_values` helper, and the protected
|
|
8
|
+
# `validate_multi_option_inclusion` helper from `Field::Optionable`.
|
|
9
|
+
# Stays a direct child of `Field::Base` — Optionable is a concern
|
|
10
|
+
# (mixin), not an intermediate STI class, because Select and
|
|
11
|
+
# MultiSelect don't share a `value_column`.
|
|
12
|
+
#
|
|
13
|
+
# `validate_array_size` is called directly from `Field::Base`
|
|
14
|
+
# (cross-family outlier; also used by `Field::IntegerArray`).
|
|
5
15
|
class MultiSelect < Base
|
|
16
|
+
include Optionable
|
|
17
|
+
|
|
6
18
|
value_column :json_value
|
|
7
19
|
operators :any_eq, :all_eq, :is_null, :is_not_null
|
|
8
20
|
|
|
9
|
-
def optionable? = true
|
|
10
21
|
def array_field? = true
|
|
11
22
|
|
|
12
|
-
def allowed_values
|
|
13
|
-
if field_options.loaded?
|
|
14
|
-
field_options.sort_by { |o| [o.sort_order || 0, o.label.to_s] }.map(&:value)
|
|
15
|
-
else
|
|
16
|
-
field_options.sorted.pluck(:value)
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
23
|
def cast(raw)
|
|
21
24
|
return [nil, false] if raw.nil?
|
|
22
25
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
# Concern (mixin) for field types whose valid values are constrained
|
|
6
|
+
# to an enumeration of `Field::Option` rows.
|
|
7
|
+
#
|
|
8
|
+
# Included by: `Field::Select`, `Field::MultiSelect`. Both leaves stay
|
|
9
|
+
# as direct children of `Field::Base` — Select uses `string_value`,
|
|
10
|
+
# MultiSelect uses `json_value`. Inheritance can't unify storage for
|
|
11
|
+
# both, so we use a concern instead of an intermediate class.
|
|
12
|
+
#
|
|
13
|
+
# Provides:
|
|
14
|
+
#
|
|
15
|
+
# - `optionable?` overridden to `true` (default on `Field::Base` is
|
|
16
|
+
# `false`).
|
|
17
|
+
# - `allowed_values` — the public-facing, **sorted** option-values
|
|
18
|
+
# helper. When `field_options` is loaded, sorts in memory by
|
|
19
|
+
# `[sort_order || 0, label]`; otherwise issues a `sorted.pluck`
|
|
20
|
+
# query. Matches the per-leaf implementation that was duplicated
|
|
21
|
+
# verbatim between Select and MultiSelect pre-refactor.
|
|
22
|
+
# - `validate_option_inclusion` / `validate_multi_option_inclusion`
|
|
23
|
+
# protected helpers (moved from `Field::Base`). Both call into
|
|
24
|
+
# `allowed_option_values` (the validator-facing fast path that still
|
|
25
|
+
# lives on `Field::Base`) so they avoid the sort overhead on the hot
|
|
26
|
+
# validation path.
|
|
27
|
+
#
|
|
28
|
+
# Public extension point: external authors can `include
|
|
29
|
+
# TypedEAV::Field::Optionable` to opt into the option-set surface
|
|
30
|
+
# without joining the Select/MultiSelect inheritance chain (see
|
|
31
|
+
# README §"Custom field types").
|
|
32
|
+
module Optionable
|
|
33
|
+
extend ActiveSupport::Concern
|
|
34
|
+
|
|
35
|
+
def optionable? = true
|
|
36
|
+
|
|
37
|
+
def allowed_values
|
|
38
|
+
if field_options.loaded?
|
|
39
|
+
field_options.sort_by { |o| [o.sort_order || 0, o.label.to_s] }.map(&:value)
|
|
40
|
+
else
|
|
41
|
+
field_options.sorted.pluck(:value)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
protected
|
|
46
|
+
|
|
47
|
+
def validate_option_inclusion(record, val)
|
|
48
|
+
return if allowed_option_values.include?(val&.to_s)
|
|
49
|
+
|
|
50
|
+
record.errors.add(:value, :inclusion)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_multi_option_inclusion(record, val)
|
|
54
|
+
invalid = Array(val).map(&:to_s) - allowed_option_values
|
|
55
|
+
record.errors.add(:value, :inclusion) if invalid.any?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -23,10 +23,10 @@ module TypedEAV
|
|
|
23
23
|
# - decimal_places: Integer >= 0 (default 2). Format-time precision.
|
|
24
24
|
# - display_as: :fraction | :percent (default :fraction).
|
|
25
25
|
class Percentage < Decimal
|
|
26
|
-
# Re-declare value_column :decimal_value.
|
|
27
|
-
#
|
|
28
|
-
# the declaring class) — Ruby class instance variables are
|
|
29
|
-
# inherited through subclass lookup, so `Percentage.value_column`
|
|
26
|
+
# Re-declare value_column :decimal_value. TypedStorage's `value_column`
|
|
27
|
+
# / `value_columns` setters write to `@value_columns` (a class instance
|
|
28
|
+
# variable on the declaring class) — Ruby class instance variables are
|
|
29
|
+
# NOT inherited through subclass lookup, so `Percentage.value_column`
|
|
30
30
|
# would raise NotImplementedError without this re-declaration.
|
|
31
31
|
# Re-declaring with the same column is BC-safe and explicit; STI
|
|
32
32
|
# behavior (the `type` column stores "TypedEAV::Field::Percentage")
|
|
@@ -38,9 +38,9 @@ module TypedEAV
|
|
|
38
38
|
validate :decimal_places_format
|
|
39
39
|
validate :display_as_inclusion
|
|
40
40
|
|
|
41
|
-
# Inherits supported_operators (DEFAULT_OPERATORS_BY_COLUMN
|
|
41
|
+
# Inherits supported_operators (TypedStorage::DEFAULT_OPERATORS_BY_COLUMN
|
|
42
42
|
# [:decimal_value]) and cast (BigDecimal parse) from Decimal.
|
|
43
|
-
# Inherits read_value / write_value /
|
|
43
|
+
# Inherits read_value / write_value / apply_default defaults from
|
|
44
44
|
# Field::Base via Decimal's chain.
|
|
45
45
|
|
|
46
46
|
def validate_typed_value(record, val)
|