typed_eav 0.1.0 → 0.2.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +634 -2
  4. data/app/models/typed_eav/field/base.rb +552 -6
  5. data/app/models/typed_eav/field/currency.rb +125 -0
  6. data/app/models/typed_eav/field/file.rb +98 -0
  7. data/app/models/typed_eav/field/image.rb +152 -0
  8. data/app/models/typed_eav/field/percentage.rb +100 -0
  9. data/app/models/typed_eav/field/reference.rb +230 -0
  10. data/app/models/typed_eav/section.rb +114 -4
  11. data/app/models/typed_eav/value.rb +461 -11
  12. data/app/models/typed_eav/value_version.rb +96 -0
  13. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
  14. data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
  15. data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
  16. data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
  17. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
  18. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
  19. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
  20. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
  21. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
  22. data/lib/typed_eav/bulk_write.rb +147 -0
  23. data/lib/typed_eav/column_mapping.rb +46 -0
  24. data/lib/typed_eav/config.rb +215 -19
  25. data/lib/typed_eav/csv_mapper.rb +158 -0
  26. data/lib/typed_eav/currency_storage_contract.rb +46 -0
  27. data/lib/typed_eav/engine.rb +117 -0
  28. data/lib/typed_eav/event_dispatcher.rb +151 -0
  29. data/lib/typed_eav/field_storage_contract.rb +68 -0
  30. data/lib/typed_eav/has_typed_eav.rb +455 -58
  31. data/lib/typed_eav/partition.rb +64 -0
  32. data/lib/typed_eav/query_builder.rb +39 -3
  33. data/lib/typed_eav/registry.rb +48 -9
  34. data/lib/typed_eav/schema_portability.rb +250 -0
  35. data/lib/typed_eav/version.rb +1 -1
  36. data/lib/typed_eav/versioned.rb +73 -0
  37. data/lib/typed_eav/versioning/subscriber.rb +161 -0
  38. data/lib/typed_eav/versioning.rb +94 -0
  39. data/lib/typed_eav.rb +180 -12
  40. metadata +36 -2
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Field
5
+ # Two-cell field type: stores `{amount: BigDecimal, currency: String}`
6
+ # across decimal_value (amount) + string_value (currency ISO 4217 code).
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
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.
23
+ #
24
+ # Operators (explicit narrowing — does NOT inherit string-search ops
25
+ # like :contains/:starts_with from decimal_value's default since those
26
+ # don't apply to amount-numeric or currency-code searches):
27
+ # - :eq, :gt, :lt, :gteq, :lteq, :between target the amount.
28
+ # - :currency_eq targets the currency code (registered ONLY on this
29
+ # class — QueryBuilder's operator-validation gate rejects it on any
30
+ # non-Currency field).
31
+ # - :is_null / :is_not_null target the amount column (a Currency value
32
+ # is considered null when its amount is null).
33
+ #
34
+ # Options:
35
+ # - default_currency: String ISO 4217 code (e.g., "USD"). Used as the
36
+ # currency fallback when cast input has amount but no currency.
37
+ # Never applies as a global silent default — only when cast input
38
+ # already has an amount and no explicit currency.
39
+ # - allowed_currencies: Array<String> of ISO codes. When set,
40
+ # validate_typed_value enforces inclusion.
41
+ class Currency < Base
42
+ value_column :decimal_value
43
+ storage_contract_class TypedEAV::CurrencyStorageContract
44
+
45
+ operators(*%i[eq gt lt gteq lteq between currency_eq is_null is_not_null])
46
+
47
+ store_accessor :options, :default_currency, :allowed_currencies
48
+
49
+ validates :default_currency, format: { with: /\A[A-Z]{3}\z/ }, allow_nil: true
50
+ validate :allowed_currencies_format
51
+
52
+ def self.value_columns
53
+ storage_contract_class.value_columns
54
+ end
55
+
56
+ def self.operator_column(operator)
57
+ storage_contract_class.query_column(operator)
58
+ end
59
+
60
+ # Cast Hash input → [{amount: BigDecimal, currency: String}, false]
61
+ # or [nil, false] for nil/blank, or [nil, true] for unparseable input.
62
+ # Bare Numeric or String input is [nil, true] — users MUST pass a
63
+ # hash to make the currency dimension explicit (locked plan-time
64
+ # decision; preventing silent default_currency reliance in the
65
+ # ergonomic-but-error-prone scalar-cast case).
66
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one cast method with linear branches for nil/non-hash/amount-parse/currency-coercion/currency-shape; splitting hides the cast contract from a single read.
67
+ def cast(raw)
68
+ return [nil, false] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
69
+ return [nil, true] unless raw.is_a?(Hash)
70
+
71
+ amount_raw = raw[:amount] || raw["amount"]
72
+ currency_raw = raw[:currency] || raw["currency"]
73
+
74
+ amount_bd = nil
75
+ if amount_raw.present?
76
+ amount_bd = BigDecimal(amount_raw.to_s, exception: false)
77
+ return [nil, true] if amount_bd.nil?
78
+ end
79
+
80
+ currency_str = currency_raw.is_a?(String) ? currency_raw.upcase : nil
81
+ # default_currency fallback applies ONLY when the hash has an
82
+ # amount but no currency. When the hash has neither, the result is
83
+ # {amount: nil, currency: nil} — falsy enough that read_value
84
+ # returns nil. When the hash has only a currency, the fallback
85
+ # does NOT trigger (amount stays nil); validation will catch the
86
+ # co-population requirement at save time.
87
+ currency_str ||= default_currency if amount_bd && default_currency.present?
88
+ # Reject non-3-letter currency codes (validation also catches this
89
+ # on save; cast catches it earlier so :invalid is set on the cast
90
+ # result and Value#validate_value surfaces :invalid promptly).
91
+ return [nil, true] if currency_str && currency_str !~ /\A[A-Z]{3}\z/
92
+
93
+ [{ amount: amount_bd, currency: currency_str }, false]
94
+ end
95
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
96
+
97
+ # Co-population validation + allowed_currencies inclusion.
98
+ # When val is a Hash, requires both :amount and :currency populated.
99
+ # When allowed_currencies is set, val[:currency] must be in the
100
+ # list. Without the co-population check, a half-populated row
101
+ # (amount-only or currency-only) would silently round-trip.
102
+ def validate_typed_value(record, val)
103
+ return if val.nil?
104
+
105
+ unless val.is_a?(Hash) && val[:amount].present? && val[:currency].present?
106
+ record.errors.add(:value, "must have both amount and currency")
107
+ return
108
+ end
109
+
110
+ allowed = options_hash[:allowed_currencies]
111
+ record.errors.add(:value, :inclusion) if allowed.present? && Array(allowed).exclude?(val[:currency])
112
+ end
113
+
114
+ private
115
+
116
+ def allowed_currencies_format
117
+ list = options_hash[:allowed_currencies]
118
+ return if list.nil?
119
+ return if list.is_a?(Array) && list.all? { |c| c.is_a?(String) && c =~ /\A[A-Z]{3}\z/ }
120
+
121
+ errors.add(:allowed_currencies, "must be an Array of 3-letter uppercase ISO codes")
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Field
5
+ # Active Storage-backed field type for non-image file attachments.
6
+ # Stores the attached blob's `signed_id` (a String) in `string_value`.
7
+ # Same shape as Field::Image — see image.rb for the full soft-detect
8
+ # rationale, signed_id storage choice, and option semantics.
9
+ #
10
+ # ## Image vs File
11
+ #
12
+ # Both classes share the same shared `:attachment` has_one_attached
13
+ # association on TypedEAV::Value (declared once in engine.rb's
14
+ # config.after_initialize block; see RESEARCH §Risk 3 for the rationale
15
+ # against per-class associations). The semantic distinction at runtime
16
+ # is `value.field.is_a?(TypedEAV::Field::Image)` vs `is_a?(File)`. The
17
+ # blob's content_type is the source of truth for image-vs-other-file.
18
+ #
19
+ # The on_image_attached hook (Phase 05) fires ONLY for Field::Image —
20
+ # File has no parallel hook by ROADMAP design. Apps that need a
21
+ # generic file-attached signal use on_value_change (Phase 03) or
22
+ # subscribe to ActiveSupport::Notifications directly.
23
+ #
24
+ # ## Namespace shadowing note
25
+ #
26
+ # `TypedEAV::Field::File` shadows Ruby's top-level `::File` constant
27
+ # inside the TypedEAV::Field namespace. Internal code that needs the
28
+ # Ruby File class (none in this file) must reference it as `::File`
29
+ # to avoid ambiguity. This is intentional and matches the gem's
30
+ # one-class-per-file STI convention; renaming to FileAttachment
31
+ # would diverge from Image (also a single-word noun).
32
+ class File < Base
33
+ value_column :string_value
34
+
35
+ operators(*%i[eq is_null is_not_null])
36
+
37
+ store_accessor :options, :allowed_content_types, :max_size_bytes
38
+
39
+ # See TypedEAV::Field::Image#cast for the full contract — File's
40
+ # cast is identical except for the NotImplementedError message.
41
+ def cast(raw)
42
+ unless defined?(::ActiveStorage::Blob)
43
+ raise NotImplementedError,
44
+ "TypedEAV::Field::File requires Active Storage. " \
45
+ "Add `gem 'activestorage'` to your Gemfile (already " \
46
+ "included via the `rails` meta-gem in Rails 7.1+) and " \
47
+ "run `bin/rails active_storage:install`."
48
+ end
49
+
50
+ return [nil, false] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
51
+ return [raw.signed_id, false] if raw.is_a?(::ActiveStorage::Blob)
52
+ return [raw, false] if raw.is_a?(String)
53
+
54
+ [nil, true]
55
+ end
56
+
57
+ # See TypedEAV::Field::Image#validate_typed_value for the full
58
+ # contract — File's validation is identical (it accepts the same
59
+ # allowed_content_types / max_size_bytes options). Apps that want
60
+ # image-only mime restrictions configure
61
+ # `allowed_content_types: ["image/*"]`; otherwise the field is a
62
+ # general-purpose attachment slot.
63
+ def validate_typed_value(record, val)
64
+ return if val.nil?
65
+ return unless defined?(::ActiveStorage::Blob)
66
+
67
+ blob = ::ActiveStorage::Blob.find_signed(val)
68
+ if blob.nil?
69
+ record.errors.add(:value, :invalid)
70
+ return
71
+ end
72
+
73
+ if allowed_content_types.present? && !content_type_matches?(blob.content_type)
74
+ record.errors.add(
75
+ :value,
76
+ "must be one of #{Array(allowed_content_types).join(", ")}",
77
+ )
78
+ end
79
+
80
+ return unless max_size_bytes.present? && blob.byte_size > max_size_bytes.to_i
81
+
82
+ record.errors.add(:value, "exceeds max size #{max_size_bytes} bytes")
83
+ end
84
+
85
+ private
86
+
87
+ def content_type_matches?(blob_content_type)
88
+ Array(allowed_content_types).any? do |pattern|
89
+ if pattern.end_with?("/*")
90
+ blob_content_type&.start_with?(pattern.sub("/*", "/"))
91
+ else
92
+ blob_content_type == pattern
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Field
5
+ # Active Storage-backed field type for image attachments. Stores the
6
+ # attached blob's `signed_id` (a String) in `string_value`. The
7
+ # actual `:attachment` has_one_attached association lives on
8
+ # TypedEAV::Value (registered in lib/typed_eav/engine.rb's
9
+ # config.after_initialize block when ActiveStorage::Blob is defined).
10
+ #
11
+ # ## Phase 05 Gating Decision 1: lazy soft-detect
12
+ #
13
+ # The gem does NOT add `add_dependency 'activestorage'` to its gemspec.
14
+ # When Active Storage is absent at runtime, this class still LOADS
15
+ # (Zeitwerk autoloads it; constants are inspectable without forcing
16
+ # AS load). #cast and #validate_typed_value short-circuit / raise on
17
+ # invocation:
18
+ #
19
+ # - #cast raises NotImplementedError with an actionable install
20
+ # message (the only cast contract that can fail-fast — every
21
+ # write path goes through cast).
22
+ # - #validate_typed_value silently no-ops when AS is unloaded
23
+ # (validation runs against blob lookup, which can't happen).
24
+ #
25
+ # Mirrors the acts_as_tenant precedent (Config::DEFAULT_SCOPE_RESOLVER
26
+ # in lib/typed_eav/config.rb lines 49-53) — `defined?(::ConstantName)`
27
+ # is the gem-wide soft-detect idiom.
28
+ #
29
+ # ## signed_id storage choice
30
+ #
31
+ # signed_id is a stable, portable, message-verified handle that
32
+ # survives blob replacement and decouples the gem's data shape from
33
+ # ActiveStorage's internal blob-id format. Storing the bare integer
34
+ # blob_id would couple the gem's persisted data to AS's primary-key
35
+ # type and prevent migrations like UUID-typed blobs. signed_id is
36
+ # always a String → string_value is the natural typed column.
37
+ #
38
+ # ## Operators
39
+ #
40
+ # Explicit narrowing to [:eq, :is_null, :is_not_null]. Numeric and
41
+ # string-pattern operators (:contains, :starts_with) don't apply to
42
+ # signed_id strings (they're message-signed opaque tokens). Presence
43
+ # checks via :is_null / :is_not_null are the canonical "does this
44
+ # entity have an attachment?" query.
45
+ #
46
+ # ## Options
47
+ #
48
+ # - allowed_content_types: Array<String> — content-type allowlist
49
+ # for validate_typed_value. Supports exact matches ("image/png")
50
+ # and wildcard families ("image/*").
51
+ # - max_size_bytes: Integer — maximum blob byte_size accepted by
52
+ # validate_typed_value. Pass as Integer or numeric String; nil
53
+ # disables the size cap.
54
+ #
55
+ # ## Attachment access
56
+ #
57
+ # Read-side: `value.attachment.attached?`, `value.attachment.blob`,
58
+ # `value.attachment.url` (Rails standard helpers — typed_eav doesn't
59
+ # wrap them).
60
+ # Write-side: `value.attachment.attach(io: ..., filename: ...,
61
+ # content_type: ...)`, then `value.update!(string_value: value
62
+ # .attachment.blob.signed_id)`. The signed_id assignment is what
63
+ # the typed_eav read path serves; the attachment association is the
64
+ # AS-native handle.
65
+ class Image < Base
66
+ value_column :string_value
67
+
68
+ operators(*%i[eq is_null is_not_null])
69
+
70
+ store_accessor :options, :allowed_content_types, :max_size_bytes
71
+
72
+ # Cast contract:
73
+ # - nil / blank → [nil, false]
74
+ # - String → treated as a signed_id; passthrough as [raw, false]
75
+ # - ActiveStorage::Blob → [blob.signed_id, false]
76
+ # - any other shape (File, Tempfile, IO, Hash) → [nil, true]
77
+ # (apps must call value.attachment.attach(io: ...) directly,
78
+ # then assign the blob's signed_id back through value=)
79
+ #
80
+ # Raises NotImplementedError when ::ActiveStorage::Blob is undefined.
81
+ # The raise lives in cast (not in the class body) so the constant
82
+ # itself loads cleanly under Zeitwerk even when AS is absent —
83
+ # consumers inspecting `TypedEAV::Field::Image.value_column` are
84
+ # not forced to install AS.
85
+ def cast(raw)
86
+ unless defined?(::ActiveStorage::Blob)
87
+ raise NotImplementedError,
88
+ "TypedEAV::Field::Image requires Active Storage. " \
89
+ "Add `gem 'activestorage'` to your Gemfile (already " \
90
+ "included via the `rails` meta-gem in Rails 7.1+) and " \
91
+ "run `bin/rails active_storage:install`."
92
+ end
93
+
94
+ return [nil, false] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
95
+ return [raw.signed_id, false] if raw.is_a?(::ActiveStorage::Blob)
96
+ return [raw, false] if raw.is_a?(String)
97
+
98
+ [nil, true]
99
+ end
100
+
101
+ # Validates a casted signed_id String. Looks up the blob via
102
+ # `ActiveStorage::Blob.find_signed` (returns nil for tampered/
103
+ # expired tokens — flagged as :invalid). When allowed_content_types
104
+ # is set, asserts blob.content_type matches one entry (exact or
105
+ # `image/*` wildcard). When max_size_bytes is set, asserts
106
+ # blob.byte_size <= the limit.
107
+ #
108
+ # Silently no-ops when Active Storage is unloaded — the raise
109
+ # happens in cast (the only path that reaches save) so this is
110
+ # defensive belt-and-suspenders. Without this guard, a soft-
111
+ # detect-aware host could call validate_typed_value directly via
112
+ # introspection and trigger NameError at runtime; the no-op
113
+ # preserves the lazy contract.
114
+ def validate_typed_value(record, val)
115
+ return if val.nil?
116
+ return unless defined?(::ActiveStorage::Blob)
117
+
118
+ blob = ::ActiveStorage::Blob.find_signed(val)
119
+ if blob.nil?
120
+ record.errors.add(:value, :invalid)
121
+ return
122
+ end
123
+
124
+ if allowed_content_types.present? && !content_type_matches?(blob.content_type)
125
+ record.errors.add(
126
+ :value,
127
+ "must be one of #{Array(allowed_content_types).join(", ")}",
128
+ )
129
+ end
130
+
131
+ return unless max_size_bytes.present? && blob.byte_size > max_size_bytes.to_i
132
+
133
+ record.errors.add(:value, "exceeds max size #{max_size_bytes} bytes")
134
+ end
135
+
136
+ private
137
+
138
+ # Match against allowed_content_types entries. An entry ending in
139
+ # `/*` (e.g., "image/*") matches any content type with the same
140
+ # family prefix; otherwise an exact string match is required.
141
+ def content_type_matches?(blob_content_type)
142
+ Array(allowed_content_types).any? do |pattern|
143
+ if pattern.end_with?("/*")
144
+ blob_content_type&.start_with?(pattern.sub("/*", "/"))
145
+ else
146
+ blob_content_type == pattern
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Field
5
+ # Decimal subclass storing fractions in 0..1 (inclusive). The "percent"
6
+ # representation (e.g., "75.0%") is a format-time concern via
7
+ # `display_as: :percent` — the underlying decimal_value column always
8
+ # stores the fraction.
9
+ #
10
+ # STI: extends Field::Decimal (subclass-of-subclass). Rails AR resolves
11
+ # the `type` column to "TypedEAV::Field::Percentage" correctly because
12
+ # default STI behavior uses the leaf class name.
13
+ #
14
+ # Storage: decimal_value (inherited from Decimal — does NOT re-declare).
15
+ # Operators: inherits Decimal's default operator set
16
+ # (DEFAULT_OPERATORS_BY_COLUMN[:decimal_value]).
17
+ # Range: hard-coded 0..1; min/max options are NOT exposed via
18
+ # store_accessor (they would conflict with the 0-1 invariant).
19
+ # Decimal's `precision_scale` option is inherited but not exposed
20
+ # here either — it would govern storage rounding only.
21
+ #
22
+ # Options (all read-side only — do NOT change what gets stored):
23
+ # - decimal_places: Integer >= 0 (default 2). Format-time precision.
24
+ # - display_as: :fraction | :percent (default :fraction).
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`
30
+ # would raise NotImplementedError without this re-declaration.
31
+ # Re-declaring with the same column is BC-safe and explicit; STI
32
+ # behavior (the `type` column stores "TypedEAV::Field::Percentage")
33
+ # is unaffected.
34
+ value_column :decimal_value
35
+
36
+ store_accessor :options, :decimal_places, :display_as
37
+
38
+ validate :decimal_places_format
39
+ validate :display_as_inclusion
40
+
41
+ # Inherits supported_operators (DEFAULT_OPERATORS_BY_COLUMN
42
+ # [:decimal_value]) and cast (BigDecimal parse) from Decimal.
43
+ # Inherits read_value / write_value / apply_default_to defaults from
44
+ # Field::Base via Decimal's chain.
45
+
46
+ def validate_typed_value(record, val)
47
+ # Inherits Decimal's range check (min/max — typically nil for
48
+ # Percentage since options are not exposed). Without `super`, a
49
+ # future addition of min/max to Percentage's options would
50
+ # silently bypass the range guard.
51
+ super
52
+ return if val.nil?
53
+
54
+ return if val.between?(0, 1)
55
+
56
+ record.errors.add(:value, "must be between 0.0 and 1.0")
57
+ end
58
+
59
+ # Format helper. Read-side only; does NOT alter what's stored in
60
+ # decimal_value.
61
+ #
62
+ # - display_as: :percent → returns "<val*100>%" rounded to
63
+ # decimal_places (e.g., 0.75 with decimal_places: 1 → "75.0%").
64
+ # - display_as: :fraction (default) → returns val.to_s
65
+ # (e.g., 0.75 → "0.75").
66
+ # - nil val → returns nil.
67
+ def format(val)
68
+ return nil if val.nil?
69
+
70
+ places = (decimal_places || 2).to_i
71
+ case display_as&.to_sym
72
+ when :percent
73
+ "#{(val * 100).round(places)}%"
74
+ else # :fraction or unset
75
+ val.to_s
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def decimal_places_format
82
+ d = options_hash[:decimal_places]
83
+ return if d.nil?
84
+
85
+ d_int = Integer(d.to_s, exception: false)
86
+ return if d_int && d_int >= 0
87
+
88
+ errors.add(:decimal_places, "must be a non-negative integer")
89
+ end
90
+
91
+ def display_as_inclusion
92
+ d = options_hash[:display_as]
93
+ return if d.nil?
94
+ return if %w[fraction percent].include?(d.to_s)
95
+
96
+ errors.add(:display_as, "must be :fraction or :percent")
97
+ end
98
+ end
99
+ end
100
+ end