Skip to content

Commit

Permalink
Precision limits (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
Exterm1nate authored Nov 28, 2024
1 parent 6f40e4e commit 8778b9a
Show file tree
Hide file tree
Showing 30 changed files with 276 additions and 156 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ Style/WordArray:
Style/MethodCallWithArgsParentheses:
Enabled: false

Style/SafeNavigationChainLength:
Enabled: false

Metrics/BlockLength:
Exclude:
- "spec/**/*"
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@
- Drop support for Rails < 7 (EOL)
- Drop support for Ruby < 3.1 (EOL)

**Breaking changes**:
- Maximum datetime precision reduced to 6 for all _Ruby_/_Rails_ versions.

While _Ruby_ allows up to 9 fractional seconds, most databases, including _PostgreSQL_, support only 6.
To ensure compatibility and prevent potential issues,
we are standardizing the precision to the minimum supported across our technology stack.

- Maximum datetime precision constant relocated.

The maximum precision value has been moved
from `ActiveFields::Casters::DateTimeCaster::MAX_PRECISION` to `ActiveFields::MAX_DATETIME_PRECISION`.

- Maximum decimal precision set to to 16383 (2**14 - 1).

While _Ruby_'s `BigDecimal` class allows extremely high precision,
PostgreSQL supports a maximum of 16383 digits after the decimal point.
To ensure compatibility, we are capping the precision at this value.
The maximum precision value is now accessible via `ActiveFields::MAX_DECIMAL_PRECISION`.

## [1.1.0] - 2024-09-10
- Added scaffold generator
- Disabled models reloading to prevent STI issues
Expand Down
2 changes: 1 addition & 1 deletion app/models/active_fields/field/date_time.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class DateTime < ActiveFields.config.field_base_class
validates :required, exclusion: [nil]
validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
validates :precision,
comparison: { greater_than_or_equal_to: 0, less_than_or_equal_to: Casters::DateTimeCaster::MAX_PRECISION },
comparison: { greater_than_or_equal_to: 0, less_than_or_equal_to: ActiveFields::MAX_DATETIME_PRECISION },
allow_nil: true

# If precision is set after attributes that depend on it, deserialization will work correctly,
Expand Down
2 changes: 1 addition & 1 deletion app/models/active_fields/field/date_time_array.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class DateTimeArray < ActiveFields.config.field_base_class

validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
validates :precision,
comparison: { greater_than_or_equal_to: 0, less_than_or_equal_to: Casters::DateTimeCaster::MAX_PRECISION },
comparison: { greater_than_or_equal_to: 0, less_than_or_equal_to: ActiveFields::MAX_DATETIME_PRECISION },
allow_nil: true

# If precision is set after attributes that depend on it, deserialization will work correctly,
Expand Down
4 changes: 3 additions & 1 deletion app/models/active_fields/field/decimal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ class Decimal < ActiveFields.config.field_base_class

validates :required, exclusion: [nil]
validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
validates :precision, comparison: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :precision,
comparison: { greater_than_or_equal_to: 0, less_than_or_equal_to: ActiveFields::MAX_DECIMAL_PRECISION },
allow_nil: true

# If precision is set after attributes that depend on it, deserialization will work correctly,
# but an incorrect internal value may be saved in the DB.
Expand Down
4 changes: 3 additions & 1 deletion app/models/active_fields/field/decimal_array.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ class DecimalArray < ActiveFields.config.field_base_class
store_accessor :options, :min, :max, :precision

validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
validates :precision, comparison: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :precision,
comparison: { greater_than_or_equal_to: 0, less_than_or_equal_to: ActiveFields::MAX_DECIMAL_PRECISION },
allow_nil: true

# If precision is set after attributes that depend on it, deserialization will work correctly,
# but an incorrect internal value may be saved in the DB.
Expand Down
1 change: 1 addition & 0 deletions lib/active_fields.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "active_fields/version"
require_relative "active_fields/constants"
require_relative "active_fields/engine"

module ActiveFields
Expand Down
4 changes: 1 addition & 3 deletions lib/active_fields/casters/date_time_caster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
module ActiveFields
module Casters
class DateTimeCaster < BaseCaster
MAX_PRECISION = RUBY_VERSION >= "3.2" ? 9 : 6 # AR max precision is 6 for old Rubies

def serialize(value)
value = value.iso8601 if value.is_a?(Date)
casted_value = caster.serialize(value)
Expand All @@ -28,7 +26,7 @@ def caster
# Use maximum precision by default to prevent the caster from truncating useful time information
# before precision is applied later
def precision
[options[:precision], MAX_PRECISION].compact.min
[options[:precision], ActiveFields::MAX_DATETIME_PRECISION].compact.min
end

def apply_precision(value)
Expand Down
7 changes: 2 additions & 5 deletions lib/active_fields/casters/decimal_caster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,11 @@ def deserialize(value)
private

def cast(value)
casted = BigDecimal(value, 0, exception: false)
casted = casted.truncate(precision) if casted && precision

casted
BigDecimal(value, 0, exception: false)&.truncate(precision)
end

def precision
options[:precision]
[options[:precision], ActiveFields::MAX_DECIMAL_PRECISION].compact.min
end
end
end
Expand Down
12 changes: 12 additions & 0 deletions lib/active_fields/constants.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module ActiveFields
# Ruby supports up to 9 fractional seconds, but PostgreSQL, like most databases, supports only 6.
# Since we use PostgreSQL, we standardize on 6.
MAX_DATETIME_PRECISION = 6

# Ruby's BigDecimal class allows extremely high precision,
# but PostgreSQL supports a maximum of 16383 digits after the decimal point.
# Since we use PostgreSQL, we limit decimal precision to 16383.
MAX_DECIMAL_PRECISION = 2**14 - 1
end
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

<div>
<%= f.label :precision %>
<%= f.number_field :precision, min: 0, max: ActiveFields::Casters::DateTimeCaster::MAX_PRECISION, disabled: active_field.persisted? %>
<%= f.number_field :precision, min: 0, max: ActiveFields::MAX_DATETIME_PRECISION, disabled: active_field.persisted? %>
</div>

<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@

<div>
<%= f.label :precision %>
<%= f.number_field :precision, min: 0, max: ActiveFields::Casters::DateTimeCaster::MAX_PRECISION, disabled: active_field.persisted? %>
<%= f.number_field :precision, min: 0, max: ActiveFields::MAX_DATETIME_PRECISION, disabled: active_field.persisted? %>
</div>

<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

<div>
<%= f.label :precision %>
<%= f.number_field :precision, min: 0, disabled: active_field.persisted? %>
<%= f.number_field :precision, min: 0, max: ActiveFields::MAX_DECIMAL_PRECISION, disabled: active_field.persisted? %>
</div>

<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@

<div>
<%= f.label :precision %>
<%= f.number_field :precision, min: 0, disabled: active_field.persisted? %>
<%= f.number_field :precision, min: 0, max: ActiveFields::MAX_DECIMAL_PRECISION, disabled: active_field.persisted? %>
</div>

<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

<div class="form-input">
<%= f.label :precision %>
<%= f.number_field :precision, min: 0, max: ActiveFields::Casters::DateTimeCaster::MAX_PRECISION, disabled: active_field.persisted? %>
<%= f.number_field :precision, min: 0, max: ActiveFields::MAX_DATETIME_PRECISION, disabled: active_field.persisted? %>
</div>

<div class="form-input">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@

<div class="form-input">
<%= f.label :precision %>
<%= f.number_field :precision, min: 0, max: ActiveFields::Casters::DateTimeCaster::MAX_PRECISION, disabled: active_field.persisted? %>
<%= f.number_field :precision, min: 0, max: ActiveFields::MAX_DATETIME_PRECISION, disabled: active_field.persisted? %>
</div>

<div class="form-input">
Expand Down
2 changes: 1 addition & 1 deletion spec/dummy/app/views/active_fields/forms/_decimal.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

<div class="form-input">
<%= f.label :precision %>
<%= f.number_field :precision, min: 0, disabled: active_field.persisted? %>
<%= f.number_field :precision, min: 0, max: ActiveFields::MAX_DECIMAL_PRECISION, disabled: active_field.persisted? %>
</div>

<div class="form-input">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@

<div class="form-input">
<%= f.label :precision %>
<%= f.number_field :precision, min: 0, disabled: active_field.persisted? %>
<%= f.number_field :precision, min: 0, max: ActiveFields::MAX_DECIMAL_PRECISION, disabled: active_field.persisted? %>
</div>

<div class="form-input">
Expand Down
8 changes: 4 additions & 4 deletions spec/factories/active_fields.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
end

trait :with_precision do
precision { rand(0..ActiveFields::Casters::DateTimeCaster::MAX_PRECISION) }
precision { rand(0..ActiveFields::MAX_DATETIME_PRECISION) }
end
end

Expand All @@ -101,7 +101,7 @@
end

trait :with_precision do
precision { rand(0..ActiveFields::Casters::DateTimeCaster::MAX_PRECISION) }
precision { rand(0..ActiveFields::MAX_DATETIME_PRECISION) }
end
end

Expand All @@ -121,7 +121,7 @@
end

trait :with_precision do
precision { rand(0..10) }
precision { rand(0..ActiveFields::MAX_DECIMAL_PRECISION) }
end
end

Expand All @@ -145,7 +145,7 @@
end

trait :with_precision do
precision { rand(0..10) }
precision { rand(0..ActiveFields::MAX_DECIMAL_PRECISION) }
end
end

Expand Down
44 changes: 30 additions & 14 deletions spec/lib/active_fields/casters/date_time_array_caster_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

RSpec.describe ActiveFields::Casters::DateTimeArrayCaster do
max_precision = ActiveFields::Casters::DateTimeCaster::MAX_PRECISION
max_precision = ActiveFields::MAX_DATETIME_PRECISION

let(:object) { described_class.new(**args) }
let(:args) { {} }
Expand Down Expand Up @@ -59,7 +59,7 @@
end

context "when array of datetime strings" do
let(:value) { [random_datetime.iso8601, random_datetime.iso8601(max_precision)] }
let(:value) { [random_datetime.iso8601, random_datetime.iso8601(max_precision + 1)] }

it { is_expected.to eq(value.map { Time.zone.parse(_1).utc.iso8601(max_precision) }) }
end
Expand Down Expand Up @@ -91,7 +91,7 @@
end

context "when array of datetime strings" do
let(:value) { [random_datetime.iso8601, random_datetime.iso8601(max_precision)] }
let(:value) { [random_datetime.iso8601, random_datetime.iso8601(max_precision + 1)] }

it { is_expected.to eq(value.map { Time.zone.parse(_1).utc.iso8601(max_precision) }) }
end
Expand Down Expand Up @@ -119,7 +119,7 @@
end

context "when array of datetime strings" do
let(:value) { [random_datetime.iso8601, random_datetime.iso8601(max_precision)] }
let(:value) { [random_datetime.iso8601, random_datetime.iso8601(max_precision + 1)] }

it { is_expected.to eq(value.map { Time.zone.parse(_1).utc.iso8601(args[:precision]) }) }
end
Expand Down Expand Up @@ -163,13 +163,17 @@
context "when array of dates" do
let(:value) { [random_date, random_date] }

it { is_expected.to eq(value.map { _1.to_time(:utc).in_time_zone }) }
it { is_expected.to eq(value.map { apply_datetime_precision(_1.to_time(:utc).in_time_zone, max_precision) }) }
end

context "when array of date strings" do
let(:value) { [random_date.iso8601, random_date.iso8601] }

it { is_expected.to eq(value.map { Date.parse(_1).to_time(:utc).in_time_zone }) }
it do
expect(call_method).to eq(
value.map { apply_datetime_precision(Date.parse(_1).to_time(:utc).in_time_zone, max_precision) },
)
end
end

context "when array of datetimes" do
Expand All @@ -179,7 +183,7 @@
end

context "when array of datetime strings" do
let(:value) { [random_datetime.utc.iso8601, random_datetime.utc.iso8601(max_precision)] }
let(:value) { [random_datetime.utc.iso8601, random_datetime.utc.iso8601(max_precision + 1)] }

it "casts elements to time objects with max precision" do
expect(call_method).to eq(
Expand All @@ -199,13 +203,17 @@
context "when array of dates" do
let(:value) { [random_date, random_date] }

it { is_expected.to eq(value.map { _1.to_time(:utc).in_time_zone }) }
it { is_expected.to eq(value.map { apply_datetime_precision(_1.to_time(:utc).in_time_zone, max_precision) }) }
end

context "when array of date strings" do
let(:value) { [random_date.iso8601, random_date.iso8601] }

it { is_expected.to eq(value.map { Date.parse(_1).to_time(:utc).in_time_zone }) }
it do
expect(call_method).to eq(
value.map { apply_datetime_precision(Date.parse(_1).to_time(:utc).in_time_zone, max_precision) },
)
end
end

context "when array of datetimes" do
Expand All @@ -215,9 +223,9 @@
end

context "when array of datetime strings" do
let(:value) { [random_datetime.utc.iso8601, random_datetime.utc.iso8601(max_precision)] }
let(:value) { [random_datetime.utc.iso8601, random_datetime.utc.iso8601(max_precision + 1)] }

it "casts elements to time objects with max precision" do
it do
expect(call_method).to eq(
value.map { apply_datetime_precision(Time.zone.parse(_1).in_time_zone, max_precision) },
)
Expand All @@ -231,13 +239,21 @@
context "when array of dates" do
let(:value) { [random_date, random_date] }

it { is_expected.to eq(value.map { _1.to_time(:utc).in_time_zone }) }
it do
expect(call_method).to eq(
value.map { apply_datetime_precision(_1.to_time(:utc).in_time_zone, args[:precision]) },
)
end
end

context "when array of date strings" do
let(:value) { [random_date.iso8601, random_date.iso8601] }

it { is_expected.to eq(value.map { Date.parse(_1).to_time(:utc).in_time_zone }) }
it do
expect(call_method).to eq(
value.map { apply_datetime_precision(Date.parse(_1).to_time(:utc).in_time_zone, args[:precision]) },
)
end
end

context "when array of datetimes" do
Expand All @@ -247,7 +263,7 @@
end

context "when array of datetime strings" do
let(:value) { [random_datetime.utc.iso8601, random_datetime.utc.iso8601(max_precision)] }
let(:value) { [random_datetime.utc.iso8601, random_datetime.utc.iso8601(max_precision + 1)] }

it "casts elements to time objects with provided precision" do
expect(call_method).to eq(
Expand Down
Loading

0 comments on commit 8778b9a

Please sign in to comment.