typed_eav 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/MIT-LICENSE +21 -0
- data/README.md +494 -0
- data/Rakefile +13 -0
- data/app/models/typed_eav/application_record.rb +7 -0
- data/app/models/typed_eav/field/base.rb +234 -0
- data/app/models/typed_eav/field/boolean.rb +22 -0
- data/app/models/typed_eav/field/color.rb +16 -0
- data/app/models/typed_eav/field/date.rb +24 -0
- data/app/models/typed_eav/field/date_array.rb +34 -0
- data/app/models/typed_eav/field/date_time.rb +29 -0
- data/app/models/typed_eav/field/decimal.rb +30 -0
- data/app/models/typed_eav/field/decimal_array.rb +31 -0
- data/app/models/typed_eav/field/email.rb +40 -0
- data/app/models/typed_eav/field/integer.rb +30 -0
- data/app/models/typed_eav/field/integer_array.rb +68 -0
- data/app/models/typed_eav/field/json.rb +26 -0
- data/app/models/typed_eav/field/long_text.rb +19 -0
- data/app/models/typed_eav/field/multi_select.rb +41 -0
- data/app/models/typed_eav/field/select.rb +28 -0
- data/app/models/typed_eav/field/text.rb +41 -0
- data/app/models/typed_eav/field/text_array.rb +36 -0
- data/app/models/typed_eav/field/url.rb +40 -0
- data/app/models/typed_eav/option.rb +24 -0
- data/app/models/typed_eav/section.rb +25 -0
- data/app/models/typed_eav/value.rb +149 -0
- data/db/migrate/20260330000000_create_typed_eav_tables.rb +132 -0
- data/lib/generators/typed_eav/install/install_generator.rb +28 -0
- data/lib/generators/typed_eav/scaffold/scaffold_generator.rb +106 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +45 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/concerns/typed_eav_controller_concern.rb +24 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +231 -0
- data/lib/generators/typed_eav/scaffold/templates/helpers/typed_eav_helper.rb +150 -0
- data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js +64 -0
- data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js +32 -0
- data/lib/generators/typed_eav/scaffold/templates/views/shared/_array_field.html.erb +23 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/edit.html.erb +47 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/finders/_form.html.erb +80 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_boolean.html.erb +12 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_color.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +57 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_time.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_email.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_json.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_long_text.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_multi_select.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_select.html.erb +14 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text.html.erb +26 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_url.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +42 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/new.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +44 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_boolean.html.erb +10 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_color.html.erb +4 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_time.html.erb +5 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_email.html.erb +5 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_json.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_long_text.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_multi_select.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_select.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb +5 -0
- data/lib/typed_eav/column_mapping.rb +64 -0
- data/lib/typed_eav/config.rb +91 -0
- data/lib/typed_eav/engine.rb +20 -0
- data/lib/typed_eav/has_typed_eav.rb +484 -0
- data/lib/typed_eav/query_builder.rb +133 -0
- data/lib/typed_eav/registry.rb +52 -0
- data/lib/typed_eav/version.rb +5 -0
- data/lib/typed_eav.rb +86 -0
- metadata +146 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module TypedEAV
|
|
6
|
+
module Field
|
|
7
|
+
class Base < ApplicationRecord
|
|
8
|
+
self.table_name = "typed_eav_fields"
|
|
9
|
+
|
|
10
|
+
include TypedEAV::ColumnMapping
|
|
11
|
+
|
|
12
|
+
# ── Associations ──
|
|
13
|
+
|
|
14
|
+
belongs_to :section,
|
|
15
|
+
class_name: "TypedEAV::Section",
|
|
16
|
+
optional: true,
|
|
17
|
+
inverse_of: :fields
|
|
18
|
+
|
|
19
|
+
has_many :values,
|
|
20
|
+
class_name: "TypedEAV::Value",
|
|
21
|
+
foreign_key: :field_id,
|
|
22
|
+
inverse_of: :field,
|
|
23
|
+
dependent: :destroy
|
|
24
|
+
|
|
25
|
+
has_many :field_options,
|
|
26
|
+
class_name: "TypedEAV::Option",
|
|
27
|
+
foreign_key: :field_id,
|
|
28
|
+
inverse_of: :field,
|
|
29
|
+
dependent: :destroy
|
|
30
|
+
|
|
31
|
+
# ── Validations ──
|
|
32
|
+
|
|
33
|
+
RESERVED_NAMES = %w[id type class created_at updated_at].freeze
|
|
34
|
+
|
|
35
|
+
validates :name, presence: true, uniqueness: { scope: %i[entity_type scope] }
|
|
36
|
+
validates :name, exclusion: { in: RESERVED_NAMES, message: "is reserved" }
|
|
37
|
+
validates :type, presence: true
|
|
38
|
+
validates :entity_type, presence: true
|
|
39
|
+
validate :validate_default_value
|
|
40
|
+
validate :validate_type_allowed_for_entity
|
|
41
|
+
|
|
42
|
+
# ── Scopes ──
|
|
43
|
+
|
|
44
|
+
scope :for_entity, lambda { |entity_type, scope: nil|
|
|
45
|
+
scopes = [scope, nil].uniq
|
|
46
|
+
where(entity_type: entity_type, scope: scopes)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
scope :sorted, -> { order(sort_order: :asc, name: :asc) }
|
|
50
|
+
scope :required_fields, -> { where(required: true) }
|
|
51
|
+
|
|
52
|
+
# ── Default value handling ──
|
|
53
|
+
# Stored in default_value_meta as {"v": <raw_value>} so the jsonb
|
|
54
|
+
# column can hold any type's default without an extra typed column.
|
|
55
|
+
|
|
56
|
+
def default_value
|
|
57
|
+
cast(default_value_meta["v"]).first
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def default_value=(val)
|
|
61
|
+
default_value_meta["v"] = val
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# ── Type casting ──
|
|
65
|
+
# Returns a tuple: [casted_value, invalid?].
|
|
66
|
+
#
|
|
67
|
+
# - casted_value is the coerced value (or nil when raw is nil/blank)
|
|
68
|
+
# - invalid? is true when raw was non-empty but unparseable for this
|
|
69
|
+
# type; Value#validate_value uses the flag to surface :invalid
|
|
70
|
+
# errors (vs :blank for nil-from-nil).
|
|
71
|
+
#
|
|
72
|
+
# Subclasses override to enforce type semantics. Default is an
|
|
73
|
+
# identity pass-through that never flags invalid.
|
|
74
|
+
#
|
|
75
|
+
# Callers that only need the coerced value should use
|
|
76
|
+
# `cast(raw).first`.
|
|
77
|
+
def cast(raw)
|
|
78
|
+
[raw, false]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# ── Introspection ──
|
|
82
|
+
|
|
83
|
+
def field_type_name
|
|
84
|
+
self.class.name.demodulize.underscore
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def array_field?
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def optionable?
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Allowed option values for select/multi-select validation.
|
|
96
|
+
# When `field_options` is already loaded (eager-load path), read from
|
|
97
|
+
# memory instead of issuing a fresh `pluck` query.
|
|
98
|
+
def allowed_option_values
|
|
99
|
+
if field_options.loaded?
|
|
100
|
+
field_options.map(&:value)
|
|
101
|
+
else
|
|
102
|
+
field_options.pluck(:value)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Kept for backward compatibility but now a no-op since we don't cache.
|
|
107
|
+
def clear_option_cache!
|
|
108
|
+
# no-op
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ── Per-type value validation (polymorphic dispatch from Value) ──
|
|
112
|
+
#
|
|
113
|
+
# Default no-op. Subclasses override to enforce their constraints
|
|
114
|
+
# (length, range, pattern, option inclusion, array size, etc.) and
|
|
115
|
+
# add errors to `record.errors`. Shared helpers below (validate_length,
|
|
116
|
+
# validate_pattern, validate_range, etc.) are available to subclasses.
|
|
117
|
+
def validate_typed_value(record, val)
|
|
118
|
+
# no-op by default
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
protected
|
|
122
|
+
|
|
123
|
+
def options_hash
|
|
124
|
+
options&.with_indifferent_access || {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_length(record, val)
|
|
128
|
+
opts = options_hash
|
|
129
|
+
str = val.to_s
|
|
130
|
+
if opts[:min_length] && str.length < opts[:min_length].to_i
|
|
131
|
+
record.errors.add(:value, :too_short, count: opts[:min_length])
|
|
132
|
+
end
|
|
133
|
+
return unless opts[:max_length] && str.length > opts[:max_length].to_i
|
|
134
|
+
|
|
135
|
+
record.errors.add(:value, :too_long, count: opts[:max_length])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_pattern(record, val)
|
|
139
|
+
opts = options_hash
|
|
140
|
+
pattern = opts[:pattern]
|
|
141
|
+
return if pattern.blank?
|
|
142
|
+
|
|
143
|
+
matched = Timeout.timeout(1) { Regexp.new(pattern).match?(val.to_s) }
|
|
144
|
+
record.errors.add(:value, :invalid) unless matched
|
|
145
|
+
rescue RegexpError
|
|
146
|
+
record.errors.add(:value, "has an invalid pattern configured")
|
|
147
|
+
rescue Timeout::Error
|
|
148
|
+
record.errors.add(:value, "pattern validation timed out")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_range(record, val)
|
|
152
|
+
opts = options_hash
|
|
153
|
+
record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min]) if opts[:min] && val < opts[:min].to_d
|
|
154
|
+
return unless opts[:max] && val > opts[:max].to_d
|
|
155
|
+
|
|
156
|
+
record.errors.add(:value, :less_than_or_equal_to, count: opts[:max])
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_date_range(record, val)
|
|
160
|
+
opts = options_hash
|
|
161
|
+
if opts[:min_date]
|
|
162
|
+
min = ::Date.parse(opts[:min_date])
|
|
163
|
+
record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_date]) if val < min
|
|
164
|
+
end
|
|
165
|
+
if opts[:max_date]
|
|
166
|
+
max = ::Date.parse(opts[:max_date])
|
|
167
|
+
record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_date]) if val > max
|
|
168
|
+
end
|
|
169
|
+
rescue ::Date::Error
|
|
170
|
+
record.errors.add(:base, "field has invalid date configuration")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_datetime_range(record, val)
|
|
174
|
+
opts = options_hash
|
|
175
|
+
if opts[:min_datetime]
|
|
176
|
+
min = ::Time.zone.parse(opts[:min_datetime])
|
|
177
|
+
record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_datetime]) if val < min
|
|
178
|
+
end
|
|
179
|
+
if opts[:max_datetime]
|
|
180
|
+
max = ::Time.zone.parse(opts[:max_datetime])
|
|
181
|
+
record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_datetime]) if val > max
|
|
182
|
+
end
|
|
183
|
+
rescue ArgumentError
|
|
184
|
+
record.errors.add(:base, "field has invalid datetime configuration")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def validate_option_inclusion(record, val)
|
|
188
|
+
return if allowed_option_values.include?(val&.to_s)
|
|
189
|
+
|
|
190
|
+
record.errors.add(:value, :inclusion)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_multi_option_inclusion(record, val)
|
|
194
|
+
invalid = Array(val).map(&:to_s) - allowed_option_values
|
|
195
|
+
record.errors.add(:value, :inclusion) if invalid.any?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_array_size(record, val)
|
|
199
|
+
opts = options_hash
|
|
200
|
+
arr = Array(val)
|
|
201
|
+
if opts[:min_size] && arr.size < opts[:min_size].to_i
|
|
202
|
+
record.errors.add(:value, :too_short, count: opts[:min_size])
|
|
203
|
+
end
|
|
204
|
+
return unless opts[:max_size] && arr.size > opts[:max_size].to_i
|
|
205
|
+
|
|
206
|
+
record.errors.add(:value, :too_long, count: opts[:max_size])
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
def validate_default_value
|
|
212
|
+
return if default_value_meta.blank? || !default_value_meta.key?("v")
|
|
213
|
+
|
|
214
|
+
raw = default_value_meta["v"]
|
|
215
|
+
return if raw.nil?
|
|
216
|
+
|
|
217
|
+
_, invalid = cast(raw)
|
|
218
|
+
errors.add(:default_value, "is not valid for this field type") if invalid
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Enforces type restrictions set via `has_typed_eav types: [...]`.
|
|
222
|
+
# Skips if the entity type isn't registered (e.g., in console before
|
|
223
|
+
# models are loaded) — this is intentional fail-open behavior since
|
|
224
|
+
# unregistered entity types have no restrictions to enforce.
|
|
225
|
+
def validate_type_allowed_for_entity
|
|
226
|
+
return unless entity_type.present? && type.present?
|
|
227
|
+
return unless TypedEAV.registry.entity_types.include?(entity_type)
|
|
228
|
+
return if TypedEAV.registry.type_allowed?(entity_type, self.class)
|
|
229
|
+
|
|
230
|
+
errors.add(:type, "#{field_type_name} is not allowed for #{entity_type}")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class Boolean < Base
|
|
6
|
+
value_column :boolean_value
|
|
7
|
+
operators :eq, :is_null, :is_not_null
|
|
8
|
+
|
|
9
|
+
def cast(raw)
|
|
10
|
+
return [nil, false] if raw.nil?
|
|
11
|
+
return [nil, false] if raw.is_a?(String) && raw.strip.empty?
|
|
12
|
+
|
|
13
|
+
recognized = %w[true false 1 0 t f yes no on off].freeze
|
|
14
|
+
unless raw == true || raw == false || raw == 0 || raw == 1 || recognized.include?(raw.to_s.strip.downcase) # rubocop:disable Style/NumericPredicate
|
|
15
|
+
return [nil, true]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
[ActiveModel::Type::Boolean.new.cast(raw), false]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class Color < Base
|
|
6
|
+
value_column :string_value
|
|
7
|
+
operators :eq, :not_eq, :is_null, :is_not_null
|
|
8
|
+
|
|
9
|
+
def cast(raw)
|
|
10
|
+
return [nil, false] if raw.nil?
|
|
11
|
+
|
|
12
|
+
[raw.to_s.strip.downcase, false]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class Date < Base
|
|
6
|
+
value_column :date_value
|
|
7
|
+
|
|
8
|
+
store_accessor :options, :min_date, :max_date
|
|
9
|
+
|
|
10
|
+
def cast(raw)
|
|
11
|
+
return [nil, false] if raw.nil?
|
|
12
|
+
|
|
13
|
+
casted = raw.is_a?(::Date) ? raw : ::Date.parse(raw.to_s)
|
|
14
|
+
[casted, false]
|
|
15
|
+
rescue ::Date::Error
|
|
16
|
+
[nil, true]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate_typed_value(record, val)
|
|
20
|
+
validate_date_range(record, val)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class DateArray < Base
|
|
6
|
+
value_column :json_value
|
|
7
|
+
operators :any_eq, :is_null, :is_not_null
|
|
8
|
+
|
|
9
|
+
store_accessor :options, :min_size, :max_size
|
|
10
|
+
|
|
11
|
+
def array_field? = true
|
|
12
|
+
|
|
13
|
+
# See IntegerArray#cast for the "invalid element → whole value invalid"
|
|
14
|
+
# rationale.
|
|
15
|
+
def cast(raw)
|
|
16
|
+
return [nil, false] if raw.nil?
|
|
17
|
+
|
|
18
|
+
elements = Array(raw).reject { |v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
19
|
+
casted = elements.map do |v|
|
|
20
|
+
::Date.parse(v.to_s)
|
|
21
|
+
rescue StandardError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
return [nil, true] if casted.any?(&:nil?) && elements.any?
|
|
25
|
+
|
|
26
|
+
[casted.presence, false]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_typed_value(record, val)
|
|
30
|
+
validate_array_size(record, val)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class DateTime < Base
|
|
6
|
+
value_column :datetime_value
|
|
7
|
+
|
|
8
|
+
store_accessor :options, :min_datetime, :max_datetime
|
|
9
|
+
|
|
10
|
+
def cast(raw)
|
|
11
|
+
return [nil, false] if raw.nil?
|
|
12
|
+
return [raw, false] if raw.is_a?(::Time)
|
|
13
|
+
|
|
14
|
+
result = ::Time.zone.parse(raw.to_s)
|
|
15
|
+
if result.nil?
|
|
16
|
+
[nil, !raw.to_s.strip.empty?]
|
|
17
|
+
else
|
|
18
|
+
[result, false]
|
|
19
|
+
end
|
|
20
|
+
rescue ArgumentError
|
|
21
|
+
[nil, true]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate_typed_value(record, val)
|
|
25
|
+
validate_datetime_range(record, val)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class Decimal < Base
|
|
6
|
+
value_column :decimal_value
|
|
7
|
+
|
|
8
|
+
store_accessor :options, :min, :max, :precision_scale
|
|
9
|
+
|
|
10
|
+
validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
|
|
11
|
+
|
|
12
|
+
def cast(raw)
|
|
13
|
+
return [nil, false] if raw.nil?
|
|
14
|
+
|
|
15
|
+
result = BigDecimal(raw.to_s, exception: false)
|
|
16
|
+
return [nil, !raw.to_s.strip.empty?] if result.nil?
|
|
17
|
+
return [result, false] if precision_scale.blank?
|
|
18
|
+
|
|
19
|
+
scale = Kernel.Integer(precision_scale, exception: false)
|
|
20
|
+
return [result, false] unless scale && scale >= 0
|
|
21
|
+
|
|
22
|
+
[result.round(scale), false]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_typed_value(record, val)
|
|
26
|
+
validate_range(record, val)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class DecimalArray < Base
|
|
6
|
+
value_column :json_value
|
|
7
|
+
operators :any_eq, :all_eq, :is_null, :is_not_null
|
|
8
|
+
|
|
9
|
+
store_accessor :options, :min_size, :max_size
|
|
10
|
+
|
|
11
|
+
def array_field? = true
|
|
12
|
+
|
|
13
|
+
# See IntegerArray#cast for the "invalid element → whole value invalid"
|
|
14
|
+
# rationale. Same pattern: any unparseable element marks the cast
|
|
15
|
+
# invalid and stores nil rather than a silently-pruned partial.
|
|
16
|
+
def cast(raw)
|
|
17
|
+
return [nil, false] if raw.nil?
|
|
18
|
+
|
|
19
|
+
elements = Array(raw).reject { |v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
20
|
+
casted = elements.map { |v| BigDecimal(v.to_s, exception: false) }
|
|
21
|
+
return [nil, true] if casted.any?(&:nil?) && elements.any?
|
|
22
|
+
|
|
23
|
+
[casted.presence, false]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_typed_value(record, val)
|
|
27
|
+
validate_array_size(record, val)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class Email < Base
|
|
6
|
+
value_column :string_value
|
|
7
|
+
|
|
8
|
+
store_accessor :options, :min_length, :max_length, :pattern
|
|
9
|
+
validate :validate_pattern_syntax
|
|
10
|
+
|
|
11
|
+
EMAIL_FORMAT = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
|
|
12
|
+
|
|
13
|
+
def cast(raw)
|
|
14
|
+
return [nil, false] if raw.nil?
|
|
15
|
+
|
|
16
|
+
[raw.to_s.strip.downcase, false]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def email_format_valid?(val)
|
|
20
|
+
EMAIL_FORMAT.match?(val)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_typed_value(record, val)
|
|
24
|
+
validate_length(record, val)
|
|
25
|
+
validate_pattern(record, val) if pattern.present?
|
|
26
|
+
record.errors.add(:value, "is not a valid email address") unless email_format_valid?(val)
|
|
27
|
+
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
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class Integer < Base
|
|
6
|
+
value_column :integer_value
|
|
7
|
+
|
|
8
|
+
store_accessor :options, :min, :max
|
|
9
|
+
|
|
10
|
+
validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
|
|
11
|
+
|
|
12
|
+
def cast(raw)
|
|
13
|
+
return [nil, false] if raw.nil?
|
|
14
|
+
|
|
15
|
+
str = raw.to_s.strip
|
|
16
|
+
return [nil, false] if str.empty?
|
|
17
|
+
|
|
18
|
+
bd = BigDecimal(str, exception: false)
|
|
19
|
+
return [nil, true] if bd.nil?
|
|
20
|
+
return [nil, true] if bd.frac != 0
|
|
21
|
+
|
|
22
|
+
[bd.to_i, false]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_typed_value(record, val)
|
|
26
|
+
validate_range(record, val)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class IntegerArray < Base
|
|
6
|
+
value_column :json_value
|
|
7
|
+
operators :any_eq, :all_eq, :is_null, :is_not_null
|
|
8
|
+
|
|
9
|
+
store_accessor :options, :min_size, :max_size, :min, :max
|
|
10
|
+
|
|
11
|
+
def array_field? = true
|
|
12
|
+
|
|
13
|
+
# Cast each element using the scalar-integer parsing rules: strings
|
|
14
|
+
# must look like integers (`/\A-?\d+\z/`). Fractional input like
|
|
15
|
+
# "1.9" is rejected instead of silently truncated to 1.
|
|
16
|
+
#
|
|
17
|
+
# If ANY element fails to cast, mark the whole value invalid and store
|
|
18
|
+
# nil — don't keep a "partially cast" array around, because a failed
|
|
19
|
+
# form re-render would drop the bad elements and confuse the user
|
|
20
|
+
# (they'd see only the good ones and not know why validation fired).
|
|
21
|
+
# This mirrors scalar `Integer#cast` which returns `[nil, true]` on
|
|
22
|
+
# invalid input.
|
|
23
|
+
def cast(raw)
|
|
24
|
+
return [nil, false] if raw.nil?
|
|
25
|
+
|
|
26
|
+
elements = Array(raw).reject { |v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
27
|
+
casted = elements.map { |v| cast_integer(v) }
|
|
28
|
+
return [nil, true] if casted.any?(&:nil?) && elements.any?
|
|
29
|
+
|
|
30
|
+
[casted.presence, false]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_typed_value(record, val)
|
|
34
|
+
validate_array_size(record, val)
|
|
35
|
+
validate_element_range(record, val)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def cast_integer(val)
|
|
41
|
+
return val if val.is_a?(Integer)
|
|
42
|
+
|
|
43
|
+
str = val.to_s
|
|
44
|
+
return nil unless str.match?(/\A-?\d+\z/)
|
|
45
|
+
|
|
46
|
+
str.to_i
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_element_range(record, val)
|
|
50
|
+
opts = options_hash
|
|
51
|
+
min_val = opts[:min]&.to_d
|
|
52
|
+
max_val = opts[:max]&.to_d
|
|
53
|
+
return unless min_val || max_val
|
|
54
|
+
|
|
55
|
+
Array(val).each do |el|
|
|
56
|
+
if min_val && el < min_val
|
|
57
|
+
record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min])
|
|
58
|
+
break
|
|
59
|
+
end
|
|
60
|
+
if max_val && el > max_val
|
|
61
|
+
record.errors.add(:value, :less_than_or_equal_to, count: opts[:max])
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class Json < Base
|
|
6
|
+
value_column :json_value
|
|
7
|
+
operators :is_null, :is_not_null
|
|
8
|
+
|
|
9
|
+
# Parse JSON strings into objects/arrays. The JSON input partial posts a
|
|
10
|
+
# string from a textarea; without parsing it would land as a JSON-
|
|
11
|
+
# encoded string in json_value instead of the intended object.
|
|
12
|
+
def cast(raw)
|
|
13
|
+
return [nil, false] if raw.nil?
|
|
14
|
+
return [raw, false] if raw.is_a?(Hash) || raw.is_a?(Array) ||
|
|
15
|
+
raw.is_a?(Numeric) || raw == true || raw == false
|
|
16
|
+
|
|
17
|
+
str = raw.to_s
|
|
18
|
+
return [nil, false] if str.strip.empty?
|
|
19
|
+
|
|
20
|
+
[::JSON.parse(str), false]
|
|
21
|
+
rescue ::JSON::ParserError
|
|
22
|
+
[nil, true]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class LongText < Base
|
|
6
|
+
value_column :text_value
|
|
7
|
+
|
|
8
|
+
store_accessor :options, :min_length, :max_length
|
|
9
|
+
|
|
10
|
+
def cast(raw)
|
|
11
|
+
[raw&.to_s, false]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def validate_typed_value(record, val)
|
|
15
|
+
validate_length(record, val)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class MultiSelect < Base
|
|
6
|
+
value_column :json_value
|
|
7
|
+
operators :any_eq, :all_eq, :is_null, :is_not_null
|
|
8
|
+
|
|
9
|
+
def optionable? = true
|
|
10
|
+
def array_field? = true
|
|
11
|
+
|
|
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
|
+
def cast(raw)
|
|
21
|
+
return [nil, false] if raw.nil?
|
|
22
|
+
|
|
23
|
+
# Rails emits a hidden "" sentinel for `select multiple: true` so an
|
|
24
|
+
# empty submission still round-trips. Drop nil/blank elements here so
|
|
25
|
+
# the inclusion check doesn't reject the form's own placeholder.
|
|
26
|
+
elements = Array(raw).filter_map do |v|
|
|
27
|
+
next nil if v.nil?
|
|
28
|
+
|
|
29
|
+
s = v.to_s
|
|
30
|
+
s.strip.empty? ? nil : s
|
|
31
|
+
end
|
|
32
|
+
[elements.presence, false]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_typed_value(record, val)
|
|
36
|
+
validate_multi_option_inclusion(record, val)
|
|
37
|
+
validate_array_size(record, val)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
class Select < Base
|
|
6
|
+
value_column :string_value
|
|
7
|
+
operators :eq, :not_eq, :is_null, :is_not_null
|
|
8
|
+
|
|
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
|
+
def cast(raw)
|
|
20
|
+
[raw&.to_s, false]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_typed_value(record, val)
|
|
24
|
+
validate_option_inclusion(record, val)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|