typed_eav 0.2.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -0
- data/README.md +165 -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 +11 -5
- 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
|
@@ -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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|