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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -0
  3. data/README.md +287 -47
  4. data/app/models/typed_eav/field/base.rb +28 -159
  5. data/app/models/typed_eav/field/currency.rb +54 -20
  6. data/app/models/typed_eav/field/date.rb +16 -1
  7. data/app/models/typed_eav/field/date_time.rb +16 -1
  8. data/app/models/typed_eav/field/decimal.rb +9 -1
  9. data/app/models/typed_eav/field/email.rb +13 -16
  10. data/app/models/typed_eav/field/integer.rb +6 -1
  11. data/app/models/typed_eav/field/long_text.rb +17 -1
  12. data/app/models/typed_eav/field/multi_select.rb +12 -9
  13. data/app/models/typed_eav/field/optionable.rb +59 -0
  14. data/app/models/typed_eav/field/percentage.rb +6 -6
  15. data/app/models/typed_eav/field/range_bounded.rb +71 -0
  16. data/app/models/typed_eav/field/select.rb +9 -10
  17. data/app/models/typed_eav/field/text.rb +11 -29
  18. data/app/models/typed_eav/field/url.rb +14 -16
  19. data/app/models/typed_eav/field/validated_string.rb +87 -0
  20. data/app/models/typed_eav/value.rb +9 -9
  21. data/lib/typed_eav/bulk_read.rb +124 -0
  22. data/lib/typed_eav/engine.rb +1 -1
  23. data/lib/typed_eav/entity_query.rb +186 -0
  24. data/lib/typed_eav/field/typed_storage.rb +205 -0
  25. data/lib/typed_eav/filter_query.rb +148 -0
  26. data/lib/typed_eav/has_typed_eav/instance_methods.rb +253 -0
  27. data/lib/typed_eav/has_typed_eav.rb +29 -793
  28. data/lib/typed_eav/partition.rb +51 -11
  29. data/lib/typed_eav/query_builder.rb +6 -7
  30. data/lib/typed_eav/scope_tuple.rb +116 -0
  31. data/lib/typed_eav/version.rb +1 -1
  32. data/lib/typed_eav/versioning/subscriber.rb +7 -6
  33. data/lib/typed_eav.rb +23 -64
  34. metadata +10 -4
  35. data/lib/typed_eav/column_mapping.rb +0 -110
  36. data/lib/typed_eav/currency_storage_contract.rb +0 -46
  37. 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
- # rubocop:disable Metrics/ClassLength -- Field::Base is the central STI parent: associations,
8
- # validations, cascade dispatch, partition-aware ordering helpers, default-value handling,
9
- # and the partition-aware backfill all live here together because they share the (entity_type,
10
- # scope, parent_scope) partition contract. Splitting into concerns would scatter that contract
11
- # and obscure the cross-cutting invariants the validators and helpers enforce together.
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::ColumnMapping
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
- # ── Phase 05 multi-cell extension points ──
248
- #
249
- # These three instance methods are the field-side surface that resolves
250
- # Value#value semantics, the write path, and the default-application
251
- # path. Single-cell field types (every built-in as of Phase 04) inherit
252
- # the defaults below and behave identically to the pre-Phase-05 direct-
253
- # column-access shape.
254
- #
255
- # Multi-cell field types (Phase 05: Currency stores `{amount, currency}`
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`. Shared helpers below (validate_length,
446
- # validate_pattern, validate_range, etc.) are available to subclasses.
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 parent_scope.blank?
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
- # Phase 05 contract:
9
- # - value_columns: [:decimal_value, :string_value]propagates through
10
- # versioning's snapshot loop (Phase 04) and the Value
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: :currency_eq → :string_value; everything else →
14
- # :decimal_value. Routed via QueryBuilder.filter (plan 05-01).
15
- # - read_value / write_value / apply_default_to: paired overrides
16
- # that compose / unpack the {amount, currency} hash across the two
17
- # physical columns. Without all three, single-cell defaults would
18
- # write a Hash to decimal_value and raise TypeMismatch.
19
- # - cast: Hash input only. Bare Numeric/String is invalid — explicit
20
- # currency dimension is required at write time. Silently defaulting
21
- # to default_currency would invite bugs where users forget the
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
- value_column :decimal_value
43
- storage_contract_class TypedEAV::CurrencyStorageContract
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
- def self.value_columns
53
- storage_contract_class.value_columns
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
- def self.operator_column(operator)
57
- storage_contract_class.query_column(operator)
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
- class Date < Base
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
- class DateTime < Base
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
- class Decimal < Base
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
- class Email < Base
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
- validate_length(record, val)
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
- class Integer < Base
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
- validate_length(record, val)
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. ColumnMapping's value_column
27
- # stores the column on `@value_column` (a class instance variable on
28
- # the declaring class) — Ruby class instance variables are NOT
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 / apply_default_to defaults from
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)