typed_eav 0.1.0 → 0.2.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 +80 -0
- data/README.md +634 -2
- data/app/models/typed_eav/field/base.rb +552 -6
- data/app/models/typed_eav/field/currency.rb +125 -0
- data/app/models/typed_eav/field/file.rb +98 -0
- data/app/models/typed_eav/field/image.rb +152 -0
- data/app/models/typed_eav/field/percentage.rb +100 -0
- data/app/models/typed_eav/field/reference.rb +230 -0
- data/app/models/typed_eav/section.rb +114 -4
- data/app/models/typed_eav/value.rb +461 -11
- data/app/models/typed_eav/value_version.rb +96 -0
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
- data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
- data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
- data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
- data/lib/typed_eav/bulk_write.rb +147 -0
- data/lib/typed_eav/column_mapping.rb +46 -0
- data/lib/typed_eav/config.rb +215 -19
- data/lib/typed_eav/csv_mapper.rb +158 -0
- data/lib/typed_eav/currency_storage_contract.rb +46 -0
- data/lib/typed_eav/engine.rb +117 -0
- data/lib/typed_eav/event_dispatcher.rb +151 -0
- data/lib/typed_eav/field_storage_contract.rb +68 -0
- data/lib/typed_eav/has_typed_eav.rb +455 -58
- data/lib/typed_eav/partition.rb +64 -0
- data/lib/typed_eav/query_builder.rb +39 -3
- data/lib/typed_eav/registry.rb +48 -9
- data/lib/typed_eav/schema_portability.rb +250 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +73 -0
- data/lib/typed_eav/versioning/subscriber.rb +161 -0
- data/lib/typed_eav/versioning.rb +94 -0
- data/lib/typed_eav.rb +180 -12
- metadata +35 -1
|
@@ -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
|