active_fields 0.1.0 → 1.0.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 (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