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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +494 -0
  5. data/Rakefile +13 -0
  6. data/app/models/typed_eav/application_record.rb +7 -0
  7. data/app/models/typed_eav/field/base.rb +234 -0
  8. data/app/models/typed_eav/field/boolean.rb +22 -0
  9. data/app/models/typed_eav/field/color.rb +16 -0
  10. data/app/models/typed_eav/field/date.rb +24 -0
  11. data/app/models/typed_eav/field/date_array.rb +34 -0
  12. data/app/models/typed_eav/field/date_time.rb +29 -0
  13. data/app/models/typed_eav/field/decimal.rb +30 -0
  14. data/app/models/typed_eav/field/decimal_array.rb +31 -0
  15. data/app/models/typed_eav/field/email.rb +40 -0
  16. data/app/models/typed_eav/field/integer.rb +30 -0
  17. data/app/models/typed_eav/field/integer_array.rb +68 -0
  18. data/app/models/typed_eav/field/json.rb +26 -0
  19. data/app/models/typed_eav/field/long_text.rb +19 -0
  20. data/app/models/typed_eav/field/multi_select.rb +41 -0
  21. data/app/models/typed_eav/field/select.rb +28 -0
  22. data/app/models/typed_eav/field/text.rb +41 -0
  23. data/app/models/typed_eav/field/text_array.rb +36 -0
  24. data/app/models/typed_eav/field/url.rb +40 -0
  25. data/app/models/typed_eav/option.rb +24 -0
  26. data/app/models/typed_eav/section.rb +25 -0
  27. data/app/models/typed_eav/value.rb +149 -0
  28. data/db/migrate/20260330000000_create_typed_eav_tables.rb +132 -0
  29. data/lib/generators/typed_eav/install/install_generator.rb +28 -0
  30. data/lib/generators/typed_eav/scaffold/scaffold_generator.rb +106 -0
  31. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +45 -0
  32. data/lib/generators/typed_eav/scaffold/templates/controllers/concerns/typed_eav_controller_concern.rb +24 -0
  33. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +231 -0
  34. data/lib/generators/typed_eav/scaffold/templates/helpers/typed_eav_helper.rb +150 -0
  35. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js +64 -0
  36. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js +32 -0
  37. data/lib/generators/typed_eav/scaffold/templates/views/shared/_array_field.html.erb +23 -0
  38. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/edit.html.erb +47 -0
  39. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/finders/_form.html.erb +80 -0
  40. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_boolean.html.erb +12 -0
  41. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_color.html.erb +11 -0
  42. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +57 -0
  43. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date.html.erb +21 -0
  44. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_array.html.erb +16 -0
  45. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_time.html.erb +21 -0
  46. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal.html.erb +21 -0
  47. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal_array.html.erb +16 -0
  48. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_email.html.erb +11 -0
  49. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer.html.erb +21 -0
  50. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer_array.html.erb +16 -0
  51. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_json.html.erb +11 -0
  52. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_long_text.html.erb +21 -0
  53. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_multi_select.html.erb +6 -0
  54. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_select.html.erb +14 -0
  55. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text.html.erb +26 -0
  56. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text_array.html.erb +16 -0
  57. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_url.html.erb +11 -0
  58. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +42 -0
  59. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/new.html.erb +7 -0
  60. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +44 -0
  61. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_boolean.html.erb +10 -0
  62. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_color.html.erb +4 -0
  63. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date.html.erb +6 -0
  64. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_array.html.erb +9 -0
  65. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_time.html.erb +5 -0
  66. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal.html.erb +6 -0
  67. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal_array.html.erb +9 -0
  68. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_email.html.erb +5 -0
  69. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer.html.erb +6 -0
  70. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer_array.html.erb +9 -0
  71. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_json.html.erb +7 -0
  72. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_long_text.html.erb +7 -0
  73. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_multi_select.html.erb +7 -0
  74. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_select.html.erb +7 -0
  75. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text.html.erb +6 -0
  76. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb +9 -0
  77. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb +5 -0
  78. data/lib/typed_eav/column_mapping.rb +64 -0
  79. data/lib/typed_eav/config.rb +91 -0
  80. data/lib/typed_eav/engine.rb +20 -0
  81. data/lib/typed_eav/has_typed_eav.rb +484 -0
  82. data/lib/typed_eav/query_builder.rb +133 -0
  83. data/lib/typed_eav/registry.rb +52 -0
  84. data/lib/typed_eav/version.rb +5 -0
  85. data/lib/typed_eav.rb +86 -0
  86. 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