typed_eav 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +165 -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
@@ -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)
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Field
5
+ # Intermediate STI base for field families that constrain a single
6
+ # comparable value by `min`/`max` bounds.
7
+ #
8
+ # Leaves: `Field::Integer`, `Field::Decimal`, `Field::Date`,
9
+ # `Field::DateTime`. `Field::Percentage` keeps its `< Decimal` chain
10
+ # (so the new full chain is
11
+ # `Percentage < Decimal < RangeBounded < Base`).
12
+ #
13
+ # Does NOT declare a `value_column` — each leaf still owns its typed
14
+ # column (`integer_value`, `decimal_value`, `date_value`,
15
+ # `datetime_value`). The family is identified by "has min/max bounds",
16
+ # not by storage shape.
17
+ #
18
+ # Hoists the protected `validate_range` / `validate_date_range` /
19
+ # `validate_datetime_range` helpers previously kept on `Field::Base`.
20
+ # Each leaf declares its own `store_accessor` (`:min`/`:max` for
21
+ # Integer/Decimal; `:min_date`/`:max_date` for Date;
22
+ # `:min_datetime`/`:max_datetime` for DateTime) and its own
23
+ # `validates :max, comparison: { greater_than_or_equal_to: :min }`
24
+ # macro using the appropriate key names. Adding the macro to
25
+ # Date/DateTime in-slice closes a latent-bug gap previously only
26
+ # caught on Integer/Decimal.
27
+ #
28
+ # Public extension point: external authors can subclass this directly
29
+ # if they want a typed numeric/temporal column with min/max guards
30
+ # (see README §"Custom field types"). STI dispatch is unaffected.
31
+ class RangeBounded < Base
32
+ protected
33
+
34
+ def validate_range(record, val)
35
+ opts = options_hash
36
+ record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min]) if opts[:min] && val < opts[:min].to_d
37
+ return unless opts[:max] && val > opts[:max].to_d
38
+
39
+ record.errors.add(:value, :less_than_or_equal_to, count: opts[:max])
40
+ end
41
+
42
+ def validate_date_range(record, val)
43
+ opts = options_hash
44
+ if opts[:min_date]
45
+ min = ::Date.parse(opts[:min_date])
46
+ record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_date]) if val < min
47
+ end
48
+ if opts[:max_date]
49
+ max = ::Date.parse(opts[:max_date])
50
+ record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_date]) if val > max
51
+ end
52
+ rescue ::Date::Error
53
+ record.errors.add(:base, "field has invalid date configuration")
54
+ end
55
+
56
+ def validate_datetime_range(record, val)
57
+ opts = options_hash
58
+ if opts[:min_datetime]
59
+ min = ::Time.zone.parse(opts[:min_datetime])
60
+ record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_datetime]) if val < min
61
+ end
62
+ if opts[:max_datetime]
63
+ max = ::Time.zone.parse(opts[:max_datetime])
64
+ record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_datetime]) if val > max
65
+ end
66
+ rescue ArgumentError
67
+ record.errors.add(:base, "field has invalid datetime configuration")
68
+ end
69
+ end
70
+ end
71
+ end
@@ -2,20 +2,19 @@
2
2
 
3
3
  module TypedEAV
4
4
  module Field
5
+ # Single-choice option-set field. Stores the chosen value in
6
+ # `string_value`. Inherits `optionable? = true`, the public-facing
7
+ # sorted `allowed_values` helper, and the protected
8
+ # `validate_option_inclusion` helper from `Field::Optionable`. Stays a
9
+ # direct child of `Field::Base` — Optionable is a concern (mixin), not
10
+ # an intermediate STI class, because Select and MultiSelect don't
11
+ # share a `value_column`.
5
12
  class Select < Base
13
+ include Optionable
14
+
6
15
  value_column :string_value
7
16
  operators :eq, :not_eq, :is_null, :is_not_null
8
17
 
9
- def optionable? = true
10
-
11
- def allowed_values
12
- if field_options.loaded?
13
- field_options.sort_by { |o| [o.sort_order || 0, o.label.to_s] }.map(&:value)
14
- else
15
- field_options.sorted.pluck(:value)
16
- end
17
- end
18
-
19
18
  def cast(raw)
20
19
  [raw&.to_s, false]
21
20
  end
@@ -2,40 +2,22 @@
2
2
 
3
3
  module TypedEAV
4
4
  module Field
5
- class Text < Base
5
+ # String-typed field with optional length / pattern guards. Storage,
6
+ # `store_accessor`, numericality validators, `max_gte_min_length`,
7
+ # `validate_pattern_syntax`, and the default `validate_typed_value`
8
+ # (length + pattern) all come from `Field::ValidatedString`. Text adds
9
+ # only `cast` (raw → String).
10
+ class Text < ValidatedString
11
+ # Re-declare value_column to populate Text's own @value_columns class
12
+ # instance variable — Ruby class ivars are NOT inherited through
13
+ # subclass lookup (the same workaround `Field::Percentage` uses
14
+ # against `Field::Decimal`). BC-safe and explicit; STI dispatch is
15
+ # unaffected (the `type` column still stores "TypedEAV::Field::Text").
6
16
  value_column :string_value
7
17
 
8
- store_accessor :options, :min_length, :max_length, :pattern
9
-
10
- validates :min_length, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
11
- validates :max_length, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
12
- validate :max_gte_min_length
13
- validate :validate_pattern_syntax
14
-
15
18
  def cast(raw)
16
19
  [raw&.to_s, false]
17
20
  end
18
-
19
- def validate_typed_value(record, val)
20
- validate_length(record, val)
21
- validate_pattern(record, val) if pattern.present?
22
- end
23
-
24
- private
25
-
26
- def max_gte_min_length
27
- return unless min_length && max_length
28
-
29
- errors.add(:max_length, "must be >= min_length") if max_length < min_length
30
- end
31
-
32
- def validate_pattern_syntax
33
- return if pattern.blank?
34
-
35
- Regexp.new(pattern)
36
- rescue RegexpError => e
37
- errors.add(:pattern, "is invalid: #{e.message}")
38
- end
39
21
  end
40
22
  end
41
23
  end
@@ -4,12 +4,21 @@ require "uri"
4
4
 
5
5
  module TypedEAV
6
6
  module Field
7
- class Url < Base
7
+ # URL-typed field. Inherits `string_value` storage shape, the
8
+ # `min_length` / `max_length` / `pattern` `store_accessor`, the
9
+ # `max_gte_min_length` guard, and the `validate_pattern_syntax` guard
10
+ # from `Field::ValidatedString`. Adds the `URL_FORMAT` regex
11
+ # (http/https only) and layers the format check onto
12
+ # `validate_typed_value` via `super`.
13
+ #
14
+ # Latent-bug fix (per ADR-0004): `max_gte_min_length` (previously
15
+ # only on Text) now applies here — a Url field configured with
16
+ # `max_length < min_length` fails at field-save.
17
+ class Url < ValidatedString
18
+ # Re-declare value_column for Ruby class-ivar non-inheritance — see
19
+ # comment on Field::Text. STI dispatch is unaffected.
8
20
  value_column :string_value
9
21
 
10
- store_accessor :options, :min_length, :max_length, :pattern
11
- validate :validate_pattern_syntax
12
-
13
22
  URL_FORMAT = /\A#{URI::DEFAULT_PARSER.make_regexp(%w[http https])}\z/
14
23
 
15
24
  def cast(raw)
@@ -21,20 +30,9 @@ module TypedEAV
21
30
  end
22
31
 
23
32
  def validate_typed_value(record, val)
24
- validate_length(record, val)
25
- validate_pattern(record, val) if pattern.present?
33
+ super
26
34
  record.errors.add(:value, "is not a valid URL") unless url_format_valid?(val)
27
35
  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
36
  end
39
37
  end
40
38
  end