active_fields 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +0 -1
  3. data/.rubocop.yml +3 -15
  4. data/CHANGELOG.md +19 -0
  5. data/README.md +633 -0
  6. data/Rakefile +7 -0
  7. data/app/models/active_fields/application_record.rb +7 -0
  8. data/app/models/active_fields/field/base.rb +17 -0
  9. data/{lib/active_fields/models → app/models/active_fields}/field/boolean.rb +14 -9
  10. data/{lib/active_fields/models → app/models/active_fields}/field/date.rb +16 -12
  11. data/app/models/active_fields/field/date_array.rb +36 -0
  12. data/app/models/active_fields/field/date_time.rb +77 -0
  13. data/app/models/active_fields/field/date_time_array.rb +61 -0
  14. data/app/models/active_fields/field/decimal.rb +75 -0
  15. data/app/models/active_fields/field/decimal_array.rb +59 -0
  16. data/{lib/active_fields/models → app/models/active_fields}/field/enum.rb +34 -17
  17. data/app/models/active_fields/field/enum_array.rb +64 -0
  18. data/{lib/active_fields/models → app/models/active_fields}/field/integer.rb +16 -12
  19. data/app/models/active_fields/field/integer_array.rb +36 -0
  20. data/{lib/active_fields/models → app/models/active_fields}/field/text.rb +16 -12
  21. data/app/models/active_fields/field/text_array.rb +39 -0
  22. data/app/models/active_fields/value.rb +15 -0
  23. data/app/models/concerns/active_fields/customizable_concern.rb +94 -0
  24. data/app/models/concerns/active_fields/field_array_concern.rb +25 -0
  25. data/app/models/concerns/active_fields/field_concern.rb +111 -0
  26. data/app/models/concerns/active_fields/value_concern.rb +83 -0
  27. data/config/routes.rb +4 -0
  28. data/{lib/generators/active_fields/install/templates/create_active_fields_tables.rb.tt → db/migrate/20240229230000_create_active_fields_tables.rb} +8 -6
  29. data/lib/active_fields/casters/base_caster.rb +8 -2
  30. data/lib/active_fields/casters/boolean_caster.rb +0 -2
  31. data/lib/active_fields/casters/date_array_caster.rb +0 -2
  32. data/lib/active_fields/casters/date_caster.rb +8 -8
  33. data/lib/active_fields/casters/date_time_array_caster.rb +19 -0
  34. data/lib/active_fields/casters/date_time_caster.rb +43 -0
  35. data/lib/active_fields/casters/decimal_array_caster.rb +0 -2
  36. data/lib/active_fields/casters/decimal_caster.rb +10 -4
  37. data/lib/active_fields/casters/enum_array_caster.rb +13 -3
  38. data/lib/active_fields/casters/enum_caster.rb +15 -3
  39. data/lib/active_fields/casters/integer_array_caster.rb +0 -2
  40. data/lib/active_fields/casters/integer_caster.rb +2 -4
  41. data/lib/active_fields/casters/text_array_caster.rb +0 -2
  42. data/lib/active_fields/casters/text_caster.rb +0 -2
  43. data/lib/active_fields/config.rb +61 -0
  44. data/lib/active_fields/customizable_config.rb +24 -0
  45. data/lib/active_fields/engine.rb +13 -0
  46. data/lib/active_fields/has_active_fields.rb +19 -0
  47. data/lib/active_fields/validators/base_validator.rb +7 -3
  48. data/lib/active_fields/validators/boolean_validator.rb +2 -4
  49. data/lib/active_fields/validators/date_array_validator.rb +3 -5
  50. data/lib/active_fields/validators/date_time_array_validator.rb +26 -0
  51. data/lib/active_fields/validators/date_time_validator.rb +19 -0
  52. data/lib/active_fields/validators/date_validator.rb +3 -5
  53. data/lib/active_fields/validators/decimal_array_validator.rb +2 -4
  54. data/lib/active_fields/validators/decimal_validator.rb +2 -4
  55. data/lib/active_fields/validators/enum_array_validator.rb +3 -5
  56. data/lib/active_fields/validators/enum_validator.rb +3 -5
  57. data/lib/active_fields/validators/integer_array_validator.rb +2 -4
  58. data/lib/active_fields/validators/integer_validator.rb +2 -4
  59. data/lib/active_fields/validators/text_array_validator.rb +2 -4
  60. data/lib/active_fields/validators/text_validator.rb +2 -4
  61. data/lib/active_fields/version.rb +1 -1
  62. data/lib/active_fields.rb +47 -7
  63. data/lib/generators/active_fields/install/USAGE +1 -1
  64. data/lib/generators/active_fields/install/install_generator.rb +14 -18
  65. data/lib/tasks/active_fields_tasks.rake +6 -0
  66. metadata +49 -36
  67. data/lib/active_fields/models/concerns/concern.rb +0 -16
  68. data/lib/active_fields/models/concerns/customizable.rb +0 -83
  69. data/lib/active_fields/models/concerns/field_concern.rb +0 -79
  70. data/lib/active_fields/models/concerns/value_concern.rb +0 -50
  71. data/lib/active_fields/models/field/date_array.rb +0 -44
  72. data/lib/active_fields/models/field/decimal.rb +0 -48
  73. data/lib/active_fields/models/field/decimal_array.rb +0 -44
  74. data/lib/active_fields/models/field/enum_array.rb +0 -59
  75. data/lib/active_fields/models/field/integer_array.rb +0 -34
  76. data/lib/active_fields/models/field/text_array.rb +0 -35
  77. data/lib/active_fields/models/field.rb +0 -27
  78. data/lib/active_fields/models/value.rb +0 -30
@@ -1,15 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../field"
4
-
5
3
  module ActiveFields
6
- class Field
7
- class Text < ActiveFields::Field
8
- store_accessor :options, :required, :min_length, :max_length
4
+ module Field
5
+ class Text < ActiveFields.config.field_base_class
6
+ acts_as_active_field(
7
+ validator: {
8
+ class_name: "ActiveFields::Validators::TextValidator",
9
+ options: -> { { required: required?, min_length: min_length, max_length: max_length } },
10
+ },
11
+ caster: {
12
+ class_name: "ActiveFields::Casters::TextCaster",
13
+ },
14
+ )
9
15
 
10
- # attribute :required, :boolean, default: false
11
- # attribute :min_length, :integer
12
- # attribute :max_length, :integer
16
+ store_accessor :options, :required, :min_length, :max_length
13
17
 
14
18
  validates :required, exclusion: [nil]
15
19
  validates :min_length, comparison: { greater_than_or_equal_to: 0 }, allow_nil: true
@@ -17,7 +21,7 @@ module ActiveFields
17
21
 
18
22
  %i[required].each do |column|
19
23
  define_method(column) do
20
- ActiveFields::Casters::BooleanCaster.new.deserialize(super())
24
+ Casters::BooleanCaster.new.deserialize(super())
21
25
  end
22
26
 
23
27
  define_method(:"#{column}?") do
@@ -25,17 +29,17 @@ module ActiveFields
25
29
  end
26
30
 
27
31
  define_method(:"#{column}=") do |other|
28
- super(ActiveFields::Casters::BooleanCaster.new.serialize(other))
32
+ super(Casters::BooleanCaster.new.serialize(other))
29
33
  end
30
34
  end
31
35
 
32
36
  %i[min_length max_length].each do |column|
33
37
  define_method(column) do
34
- ActiveFields::Casters::IntegerCaster.new.deserialize(super())
38
+ Casters::IntegerCaster.new.deserialize(super())
35
39
  end
36
40
 
37
41
  define_method(:"#{column}=") do |other|
38
- super(ActiveFields::Casters::IntegerCaster.new.serialize(other))
42
+ super(Casters::IntegerCaster.new.serialize(other))
39
43
  end
40
44
  end
41
45
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveFields
4
+ module Field
5
+ class TextArray < ActiveFields.config.field_base_class
6
+ acts_as_active_field(
7
+ array: true,
8
+ validator: {
9
+ class_name: "ActiveFields::Validators::TextArrayValidator",
10
+ options: -> do
11
+ { min_length: min_length, max_length: max_length, min_size: min_size, max_size: max_size }
12
+ end,
13
+ },
14
+ caster: {
15
+ class_name: "ActiveFields::Casters::TextArrayCaster",
16
+ },
17
+ )
18
+
19
+ store_accessor :options, :min_length, :max_length
20
+
21
+ validates :min_length, comparison: { greater_than_or_equal_to: 0 }, allow_nil: true
22
+ validates :max_length, comparison: { greater_than_or_equal_to: ->(r) { r.min_length || 0 } }, allow_nil: true
23
+
24
+ %i[min_length max_length].each do |column|
25
+ define_method(column) do
26
+ Casters::IntegerCaster.new.deserialize(super())
27
+ end
28
+
29
+ define_method(:"#{column}=") do |other|
30
+ super(Casters::IntegerCaster.new.serialize(other))
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def set_defaults; end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveFields
4
+ # If value base class has been changed, we should prevent this model from being loaded.
5
+ # Since we cannot remove it entirely, we will not add any functionality to it.
6
+ if ActiveFields.config.value_class_changed?
7
+ class Value; end
8
+ else
9
+ class Value < ApplicationRecord
10
+ self.table_name = "active_fields_values"
11
+
12
+ include ValueConcern
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveFields
4
+ # Model mix-in that adds the active fields functionality to the customizable model
5
+ module CustomizableConcern
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # rubocop:disable Rails/ReflectionClassName
10
+ has_many :active_values,
11
+ class_name: ActiveFields.config.value_class_name,
12
+ as: :customizable,
13
+ inverse_of: :customizable,
14
+ autosave: true,
15
+ dependent: :destroy
16
+ # rubocop:enable Rails/ReflectionClassName
17
+
18
+ accepts_nested_attributes_for :active_values, allow_destroy: true
19
+ end
20
+
21
+ def active_fields
22
+ ActiveFields.config.field_base_class.for(model_name.name)
23
+ end
24
+
25
+ # Assigns the given attributes to the active_values association.
26
+ #
27
+ # Accepts an Array of Hashes (symbol/string keys) or permitted params.
28
+ # Each element should contain a <tt>:name</tt> key matching an existing active_field record.
29
+ # Element with a <tt>:value</tt> key will create an active_value if it doesn't exist
30
+ # or update an existing active_value, with the provided value.
31
+ # Element with a <tt>:_destroy</tt> key set to a truthy value will mark the
32
+ # matched active_value for destruction.
33
+ #
34
+ # Example:
35
+ #
36
+ # customizable.active_fields_attributes = [
37
+ # { name: "integer_array", value: [1, 4, 5, 5, 0] }, # create or update (symbol keys)
38
+ # { "name" => "text", "value" => "Lasso" }, # create or update (string keys)
39
+ # { name: "date", _destroy: true }, # destroy (symbol keys)
40
+ # { "name" => "boolean", "_destroy" => true }, # destroy (string keys)
41
+ # permitted_params, # params could be passed, but they must be permitted
42
+ # ]
43
+ def active_fields_attributes=(attributes)
44
+ attributes = attributes.to_h if attributes.respond_to?(:permitted?)
45
+
46
+ unless attributes.is_a?(Array) || attributes.is_a?(Hash)
47
+ raise ArgumentError, "Array or Hash expected for `active_fields=`, got #{attributes.class.name}"
48
+ end
49
+
50
+ # Handle `fields_for` params
51
+ attributes = attributes.values if attributes.is_a?(Hash)
52
+
53
+ active_fields_by_name = active_fields.index_by(&:name)
54
+ active_values_by_field_id = active_values.index_by(&:active_field_id)
55
+
56
+ nested_attributes = attributes.filter_map do |active_value_attributes|
57
+ # Convert params to Hash
58
+ active_value_attributes = active_value_attributes.to_h if active_value_attributes.respond_to?(:permitted?)
59
+ active_value_attributes = active_value_attributes.with_indifferent_access
60
+
61
+ active_field = active_fields_by_name[active_value_attributes[:name]]
62
+ next if active_field.nil?
63
+
64
+ active_value = active_values_by_field_id[active_field.id]
65
+
66
+ if has_destroy_flag?(active_value_attributes)
67
+ # Destroy
68
+ { id: active_value&.id, _destroy: true }
69
+ elsif active_value
70
+ # Update
71
+ { id: active_value.id, value: active_value_attributes[:value] }
72
+ else
73
+ # Create
74
+ { active_field: active_field, value: active_value_attributes[:value] }
75
+ end
76
+ end
77
+
78
+ self.active_values_attributes = nested_attributes
79
+ end
80
+
81
+ alias_method :active_fields=, :active_fields_attributes=
82
+
83
+ # Build an active_value, if it doesn't exist, with a default value for each available active_field
84
+ def initialize_active_values
85
+ existing_field_ids = active_values.map(&:active_field_id)
86
+
87
+ active_fields.each do |active_field|
88
+ next if existing_field_ids.include?(active_field.id)
89
+
90
+ active_values.new(active_field: active_field, value: active_field.default_value)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveFields
4
+ # Model mix-in that adds array functionality to the active fields model
5
+ module FieldArrayConcern
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ store_accessor :options, :min_size, :max_size
10
+
11
+ validates :min_size, comparison: { greater_than_or_equal_to: 0 }, allow_nil: true
12
+ validates :max_size, comparison: { greater_than_or_equal_to: ->(r) { r.min_size || 0 } }, allow_nil: true
13
+
14
+ %i[min_size max_size].each do |column|
15
+ define_method(column) do
16
+ Casters::IntegerCaster.new.deserialize(super())
17
+ end
18
+
19
+ define_method(:"#{column}=") do |other|
20
+ super(Casters::IntegerCaster.new.serialize(other))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveFields
4
+ # Model mix-in with a base logic for the active fields model
5
+ module FieldConcern
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # rubocop:disable Rails/ReflectionClassName
10
+ has_many :active_values,
11
+ class_name: ActiveFields.config.value_class_name,
12
+ foreign_key: :active_field_id,
13
+ inverse_of: :active_field,
14
+ dependent: :destroy
15
+ # rubocop:enable Rails/ReflectionClassName
16
+
17
+ scope :for, ->(customizable_type) { where(customizable_type: customizable_type) }
18
+
19
+ validates :type, presence: true
20
+ validates :name, presence: true, uniqueness: { scope: :customizable_type }
21
+ validates :name, format: { with: /\A[a-z0-9_]+\z/ }, allow_blank: true
22
+ validate :validate_default_value
23
+ validate :validate_customizable_model_allows_type
24
+
25
+ after_initialize :set_defaults
26
+ end
27
+
28
+ class_methods do
29
+ def acts_as_active_field(array: false, validator:, caster:)
30
+ include FieldArrayConcern if array
31
+
32
+ define_method(:array?) { array }
33
+
34
+ define_method(:value_validator_class) do
35
+ @value_validator_class ||= validator[:class_name].constantize
36
+ end
37
+
38
+ define_method(:value_validator) do
39
+ options =
40
+ if validator[:options].nil?
41
+ {}
42
+ elsif validator[:options].arity == 0
43
+ instance_exec(&validator[:options])
44
+ else
45
+ validator[:options].call(self)
46
+ end
47
+ value_validator_class.new(**options)
48
+ end
49
+
50
+ define_method(:value_caster_class) do
51
+ @value_caster_class ||= caster[:class_name].constantize
52
+ end
53
+
54
+ define_method(:value_caster) do
55
+ options =
56
+ if caster[:options].nil?
57
+ {}
58
+ elsif caster[:options].arity == 0
59
+ instance_exec(&caster[:options])
60
+ else
61
+ caster[:options].call(self)
62
+ end
63
+ value_caster_class.new(**options)
64
+ end
65
+ end
66
+ end
67
+
68
+ def customizable_model
69
+ customizable_type.safe_constantize
70
+ end
71
+
72
+ def default_value=(v)
73
+ default_value_meta["const"] = value_caster.serialize(v)
74
+ end
75
+
76
+ def default_value
77
+ value_caster.deserialize(default_value_meta["const"])
78
+ end
79
+
80
+ def type_name
81
+ ActiveFields.config.fields.key(type)
82
+ end
83
+
84
+ private
85
+
86
+ def validate_default_value
87
+ validator = value_validator
88
+ return if validator.validate(default_value)
89
+
90
+ validator.errors.each do |error|
91
+ if error.is_a?(Array) && error.size == 2 && error.first.is_a?(Symbol) && error.last.is_a?(Hash)
92
+ errors.add(:default_value, error.first, **error.last)
93
+ elsif error.is_a?(Symbol)
94
+ errors.add(:default_value, *error)
95
+ else
96
+ raise ArgumentError
97
+ end
98
+ end
99
+ end
100
+
101
+ def validate_customizable_model_allows_type
102
+ allowed_types = customizable_model&.active_fields_config&.types || []
103
+ return true if allowed_types.include?(type_name)
104
+
105
+ errors.add(:customizable_type, :inclusion)
106
+ false
107
+ end
108
+
109
+ def set_defaults; end
110
+ end
111
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveFields
4
+ # Model mix-in with a base logic for the active fields value model
5
+ module ValueConcern
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ belongs_to :customizable, polymorphic: true, optional: false, inverse_of: :active_values
10
+ # rubocop:disable Rails/ReflectionClassName
11
+ belongs_to :active_field,
12
+ class_name: ActiveFields.config.field_base_class_name,
13
+ optional: false,
14
+ inverse_of: :active_values
15
+ # rubocop:enable Rails/ReflectionClassName
16
+
17
+ validates :active_field, uniqueness: { scope: :customizable }
18
+ validate :validate_value
19
+ validate :validate_customizable_allowed
20
+
21
+ before_validation :assign_value_from_temp, if: -> { temp_value && active_field }
22
+ end
23
+
24
+ delegate :name, to: :active_field, allow_nil: true
25
+
26
+ attr_reader :temp_value
27
+
28
+ def value=(v)
29
+ if active_field
30
+ clear_temp_value
31
+ value_meta["const"] = active_field.value_caster.serialize(v)
32
+ else
33
+ assign_temp_value(v)
34
+ end
35
+ end
36
+
37
+ def value
38
+ return unless active_field
39
+
40
+ assign_value_from_temp if temp_value
41
+
42
+ active_field.value_caster.deserialize(value_meta["const"])
43
+ end
44
+
45
+ private
46
+
47
+ def validate_value
48
+ return if (validator = active_field&.value_validator).nil?
49
+ return if validator.validate(value)
50
+
51
+ validator.errors.each do |error|
52
+ if error.is_a?(Array) && error.size == 2 && error.first.is_a?(Symbol) && error.last.is_a?(Hash)
53
+ errors.add(:value, error.first, **error.last)
54
+ elsif error.is_a?(Symbol)
55
+ errors.add(:value, *error)
56
+ else
57
+ raise ArgumentError
58
+ end
59
+ end
60
+ end
61
+
62
+ def validate_customizable_allowed
63
+ return true if active_field.nil?
64
+ return true if customizable_type == active_field.customizable_type
65
+
66
+ errors.add(:customizable, :invalid)
67
+ false
68
+ end
69
+
70
+ # Wrap the provided value to differentiate between explicitly setting it to nil and not setting it at all
71
+ def assign_temp_value(v)
72
+ @temp_value = { "value" => v }
73
+ end
74
+
75
+ def clear_temp_value
76
+ @temp_value = nil
77
+ end
78
+
79
+ def assign_value_from_temp
80
+ self.value = temp_value["value"]
81
+ end
82
+ end
83
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveFields::Engine.routes.draw do
4
+ end
@@ -1,15 +1,17 @@
1
- class CreateActiveFieldsTables < ActiveRecord::Migration<%= migration_version %>
1
+ # frozen_string_literal: true
2
+
3
+ class CreateActiveFieldsTables < ActiveRecord::Migration[6.0]
2
4
  def change
3
5
  create_table :active_fields do |t|
4
6
  t.string :name, null: false
5
7
  t.string :type, null: false
6
8
  t.string :customizable_type, null: false
7
- t.jsonb :default_value
9
+ t.jsonb :default_value_meta, null: false, default: {}
8
10
  t.jsonb :options, null: false, default: {}
9
11
 
10
12
  t.timestamps
11
13
 
12
- t.index [:name, :customizable_type], unique: true
14
+ t.index %i[name customizable_type], unique: true
13
15
  t.index :customizable_type
14
16
  end
15
17
 
@@ -17,12 +19,12 @@ class CreateActiveFieldsTables < ActiveRecord::Migration<%= migration_version %>
17
19
  t.references :customizable, polymorphic: true, null: false, index: false
18
20
  t.references :active_field,
19
21
  null: false,
20
- foreign_key: { to_table: :active_fields, name: :active_fields_values_active_field_id_fk }
21
- t.jsonb :value
22
+ foreign_key: { to_table: :active_fields, name: "active_fields_values_active_field_id_fk" }
23
+ t.jsonb :value_meta, null: false, default: {}
22
24
 
23
25
  t.timestamps
24
26
 
25
- t.index [:customizable_type, :customizable_id, :active_field_id],
27
+ t.index %i[customizable_type customizable_id active_field_id],
26
28
  unique: true,
27
29
  name: "index_active_fields_values_on_customizable_and_field"
28
30
  end
@@ -4,12 +4,18 @@ module ActiveFields
4
4
  module Casters
5
5
  # Typecasts the active_value value
6
6
  class BaseCaster
7
- # To raw DB value
7
+ attr_reader :options
8
+
9
+ def initialize(**options)
10
+ @options = options
11
+ end
12
+
13
+ # To raw AR attribute value
8
14
  def serialize(value)
9
15
  raise NotImplementedError
10
16
  end
11
17
 
12
- # From raw DB value
18
+ # From raw AR attribute value
13
19
  def deserialize(value)
14
20
  raise NotImplementedError
15
21
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base_caster"
4
-
5
3
  module ActiveFields
6
4
  module Casters
7
5
  class BooleanCaster < BaseCaster
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "date_caster"
4
-
5
3
  module ActiveFields
6
4
  module Casters
7
5
  class DateArrayCaster < DateCaster
@@ -1,24 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base_caster"
4
-
5
3
  module ActiveFields
6
4
  module Casters
7
5
  class DateCaster < BaseCaster
8
6
  def serialize(value)
9
- cast(value)&.iso8601
7
+ casted_value = caster.serialize(value)
8
+
9
+ casted_value.iso8601 if casted_value.is_a?(Date)
10
10
  end
11
11
 
12
12
  def deserialize(value)
13
- cast(value)
13
+ casted_value = caster.deserialize(value)
14
+
15
+ casted_value if casted_value.is_a?(Date)
14
16
  end
15
17
 
16
18
  private
17
19
 
18
- def cast(value)
19
- casted_value = ActiveModel::Type::Date.new.cast(value)
20
-
21
- casted_value if casted_value.acts_like?(:date)
20
+ def caster
21
+ ActiveRecord::Type::Date.new
22
22
  end
23
23
  end
24
24
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveFields
4
+ module Casters
5
+ class DateTimeArrayCaster < DateTimeCaster
6
+ def serialize(value)
7
+ return unless value.is_a?(Array)
8
+
9
+ value.map { super(_1) }
10
+ end
11
+
12
+ def deserialize(value)
13
+ return unless value.is_a?(Array)
14
+
15
+ value.map { super(_1) }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveFields
4
+ module Casters
5
+ class DateTimeCaster < BaseCaster
6
+ MAX_PRECISION = RUBY_VERSION >= "3.2" ? 9 : 6 # AR max precision is 6 for old Rubies
7
+
8
+ def serialize(value)
9
+ value = value.iso8601 if value.is_a?(Date)
10
+ casted_value = caster.serialize(value)
11
+
12
+ casted_value.iso8601(precision) if casted_value.acts_like?(:time)
13
+ end
14
+
15
+ def deserialize(value)
16
+ value = value.iso8601 if value.is_a?(Date)
17
+ casted_value = caster.deserialize(value)
18
+
19
+ apply_precision(casted_value).in_time_zone if casted_value.acts_like?(:time)
20
+ end
21
+
22
+ private
23
+
24
+ def caster
25
+ ActiveRecord::Type::DateTime.new
26
+ end
27
+
28
+ # Use maximum precision by default to prevent the caster from truncating useful time information
29
+ # before precision is applied later
30
+ def precision
31
+ [options[:precision], MAX_PRECISION].compact.min
32
+ end
33
+
34
+ def apply_precision(value)
35
+ number_of_insignificant_digits = 9 - precision
36
+ round_power = 10**number_of_insignificant_digits
37
+ rounded_off_nsec = value.nsec % round_power
38
+
39
+ value.change(nsec: value.nsec - rounded_off_nsec)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "decimal_caster"
4
-
5
3
  module ActiveFields
6
4
  module Casters
7
5
  class DecimalArrayCaster < DecimalCaster
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base_caster"
4
-
5
3
  module ActiveFields
6
4
  module Casters
7
5
  class DecimalCaster < BaseCaster
8
6
  def serialize(value)
9
- cast(value)
7
+ # Decimals should be saved as strings to avoid potential precision loss when stored in JSON
8
+ cast(value)&.to_s
10
9
  end
11
10
 
12
11
  def deserialize(value)
@@ -16,7 +15,14 @@ module ActiveFields
16
15
  private
17
16
 
18
17
  def cast(value)
19
- BigDecimal(value, 0, exception: false)
18
+ casted = BigDecimal(value, 0, exception: false)
19
+ casted = casted.truncate(precision) if casted && precision
20
+
21
+ casted
22
+ end
23
+
24
+ def precision
25
+ options[:precision]
20
26
  end
21
27
  end
22
28
  end
@@ -1,9 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "text_array_caster"
4
-
5
3
  module ActiveFields
6
4
  module Casters
7
- class EnumArrayCaster < TextArrayCaster; end
5
+ class EnumArrayCaster < EnumCaster
6
+ def serialize(value)
7
+ return unless value.is_a?(Array)
8
+
9
+ value.map { super(_1) }
10
+ end
11
+
12
+ def deserialize(value)
13
+ return unless value.is_a?(Array)
14
+
15
+ value.map { super(_1) }
16
+ end
17
+ end
8
18
  end
9
19
  end