From 1b3f924bc511fe3c605ec08ddb970ee20ed3f694 Mon Sep 17 00:00:00 2001 From: Memli-Sheremeti Date: Wed, 2 Sep 2026 14:14:17 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(datable):=20lire=20un=20jour=20nu=20com?= =?UTF-8?q?me=20la=20journ=C3=A9e=20enti=C3=A8re=20sur=20colonne=20datetim?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur une colonne qui porte un instant, une borne donnée en jour nu était lue comme le minuit qui ouvre ce jour : `since=2026-03-01` laissait tomber les enregistrements de 09:00 et de 17:30, et ne gardait qu'un éventuel minuit. - `since` admet le jour entier, `after` l'exclut entier, `from`/`to` bornent du premier au dernier instant du jour nommé - une borne portant une heure reste honorée à la seconde - une colonne `date` garde exactement le comportement qu'elle avait - les journées sont bornées à minuit dans `Time.zone` quand l'application en définit un, et par jour suivant plutôt qu'en ajoutant 24 h, qu'un changement d'heure rendrait faux --- README.md | 26 +++++++++++ lib/filterable/datable.rb | 82 +++++++++++++++++++++++++++++++++ lib/filterable/datable/after.rb | 9 +++- lib/filterable/datable/range.rb | 27 ++++++++++- lib/filterable/datable/since.rb | 9 +++- spec/datable_datetime_spec.rb | 70 ++++++++++++++++++++++++++++ spec/spec_helper.rb | 2 +- spec/support/schema.rb | 1 + 8 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 spec/datable_datetime_spec.rb diff --git a/README.md b/README.md index 7f3dbd1..57f058b 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,32 @@ MovementDetail.filterable( Unparseable values are dropped silently (the filter is a no-op), so a bad query param can never raise. +#### Datetime columns: a bare day names the whole day + +On a column holding an **instant** (`t.datetime`), a bound given as a bare day +(`2026-03-01`) names that day **whole**, both edges included. Read as an instant +it would mean the day's midnight, and would drop every row recorded during the +day it names: + +```ruby +# quoted_at is a datetime column; rows at 09:00 and at 17:30 on 2026-03-01 +MovementDetail.filterable(filters: { quoted_at: { since: '2026-03-01' } }) +# => both rows, not just a midnight one + +MovementDetail.filterable(filters: { quoted_at: { after: '2026-03-01' } }) +# => neither: the day is excluded whole, the first instant kept is the next midnight +``` + +A bound carrying an **hour** asks about an instant and is honoured to the second: + +```ruby +MovementDetail.filterable(filters: { quoted_at: { since: '2026-03-01T12:00:00Z' } }) +# => the 09:00 row only +``` + +Days are bounded at midnight in `Time.zone` when the application sets one, and +in the system zone otherwise. On a `t.date` column nothing changes. + ### Equality filters (`Equatable`) Each declared equatable attribute reads `filters[]` directly: a diff --git a/lib/filterable/datable.rb b/lib/filterable/datable.rb index 62dab3e..cb562bf 100644 --- a/lib/filterable/datable.rb +++ b/lib/filterable/datable.rb @@ -24,6 +24,88 @@ def parse(value) end end + BARE_DAY = /\A\d{4}-\d{2}-\d{2}\z/ + + # Whether a raw bound names a day and no hour. +DateTime+ is checked ahead of + # +Date+ because it inherits from it, so an instant would otherwise be taken + # for a bare day and widened to the whole of it. + # + # @api private + # @param value [Date, Time, String] the raw bound from the params. + # @return [Boolean] + def bare_day?(value) + case value + when nil, DateTime, Time then false + when Date then true + else value.to_s.match?(BARE_DAY) + end + end + + # The column type a declared target resolves to, on the model it belongs to. + # + # @api private + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @param target [Symbol, String, Hash] the declared target. + # @return [Symbol, nil] e.g. +:date+ or +:datetime+; nil when unresolvable. + def column_type(scope, target) + klass, column = + if target.is_a?(Hash) + path, name = Filterable::Target.unpack(target) + return if name.is_a?(Hash) || path.empty? + + [Filterable::Target.resolve(scope.klass, path)&.[](:klass), name] + else + [scope.klass, target] + end + + klass&.type_for_attribute(column.to_s)&.type + end + + # Whether a bound should be read as naming a whole day: a bare day asked of a + # column that holds an instant. On a date column, or for a bound carrying an + # hour, the comparison stays exactly what it was. + # + # @api private + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @param target [Symbol, String, Hash] the declared target. + # @param value [Date, Time, String] the raw bound from the params. + # @return [Boolean] + def whole_day?(scope, target, value) + bare_day?(value) && column_type(scope, target) == :datetime + end + + # The first instant of the day a value names. + # + # @api private + # @return [Time, ActiveSupport::TimeWithZone] + def day_start(value) + midnight(to_day(value)) + end + + # The first instant of the day after the one a value names — the exclusive + # edge of that day. Built from the next day rather than by adding 24 hours, + # which a DST change makes wrong. + # + # @api private + # @return [Time, ActiveSupport::TimeWithZone] + def day_end(value) + midnight(to_day(value) + 1) + end + + # @api private + # @return [Date] + def to_day(value) + value.is_a?(Date) ? value.to_date : Date.parse(value.to_s) + end + + # Midnight opening a day, in the application zone when there is one. + # + # @api private + # @return [Time, ActiveSupport::TimeWithZone] + def midnight(day) + Time.zone ? day.in_time_zone : day.to_time + end + # Whitelist the params to the declared datable attributes, keeping their # public names. An attribute whose value is not a hash of bounds (e.g. # +filters[date]=2026-01-01+ from a query string) is dropped, so a malformed diff --git a/lib/filterable/datable/after.rb b/lib/filterable/datable/after.rb index 40f8a25..43e1174 100644 --- a/lib/filterable/datable/after.rb +++ b/lib/filterable/datable/after.rb @@ -15,8 +15,13 @@ module After # @return [ActiveRecord::Relation] the narrowed relation. def call(params, scope) entries = Filterable::Datable.accepted(params, scope, :after) - entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)| - Filterable::Target.narrow(sub_scope, target) { |field| field.gt(parsed[:after]) } + entries.reduce(scope) do |sub_scope, (_name, target, bounds, parsed)| + if Filterable::Datable.whole_day?(sub_scope, target, bounds[:after]) + edge = Filterable::Datable.day_end(bounds[:after]) + Filterable::Target.narrow(sub_scope, target) { |field| field.gteq(edge) } + else + Filterable::Target.narrow(sub_scope, target) { |field| field.gt(parsed[:after]) } + end end end diff --git a/lib/filterable/datable/range.rb b/lib/filterable/datable/range.rb index b8a3fe9..14c8a50 100644 --- a/lib/filterable/datable/range.rb +++ b/lib/filterable/datable/range.rb @@ -16,13 +16,36 @@ module Range # @return [ActiveRecord::Relation] the narrowed relation. def call(params, scope) entries = Filterable::Datable.accepted(params, scope, :from, :to) - entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)| + entries.reduce(scope) do |sub_scope, (_name, target, bounds, parsed)| + floor = floor_for(sub_scope, target, bounds[:from], parsed[:from]) Filterable::Target.narrow(sub_scope, target) do |field| - field.gteq(parsed[:from]).and(field.lteq(parsed[:to])) + field.gteq(floor).and(ceiling_for(field, sub_scope, target, bounds[:to], parsed[:to])) end end end + # The inclusive floor: the day's first instant when +from+ names a whole day, + # the parsed bound otherwise. + # + # @api private + # @return [Date, Time] + def floor_for(scope, target, raw, parsed) + return parsed unless Filterable::Datable.whole_day?(scope, target, raw) + + Filterable::Datable.day_start(raw) + end + + # The inclusive ceiling: a whole day is admitted up to the next midnight, + # excluded, so every instant of the day it names is kept. + # + # @api private + # @return [Arel::Nodes::Node] + def ceiling_for(field, scope, target, raw, parsed) + return field.lteq(parsed) unless Filterable::Datable.whole_day?(scope, target, raw) + + field.lt(Filterable::Datable.day_end(raw)) + end + # The bounds {call} would apply, keyed by public name, with their raw values. # # @api private diff --git a/lib/filterable/datable/since.rb b/lib/filterable/datable/since.rb index ded420e..c0e8f8a 100644 --- a/lib/filterable/datable/since.rb +++ b/lib/filterable/datable/since.rb @@ -15,8 +15,13 @@ module Since # @return [ActiveRecord::Relation] the narrowed relation. def call(params, scope) entries = Filterable::Datable.accepted(params, scope, :since) - entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)| - Filterable::Target.narrow(sub_scope, target) { |field| field.lteq(parsed[:since]) } + entries.reduce(scope) do |sub_scope, (_name, target, bounds, parsed)| + if Filterable::Datable.whole_day?(sub_scope, target, bounds[:since]) + edge = Filterable::Datable.day_end(bounds[:since]) + Filterable::Target.narrow(sub_scope, target) { |field| field.lt(edge) } + else + Filterable::Target.narrow(sub_scope, target) { |field| field.lteq(parsed[:since]) } + end end end diff --git a/spec/datable_datetime_spec.rb b/spec/datable_datetime_spec.rb new file mode 100644 index 0000000..abea63b --- /dev/null +++ b/spec/datable_datetime_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# A datable column holding an instant is still asked about in days: a consumer sending +# `since=2026-03-01` means that day, not the midnight opening it. Read as an instant, +# such a bound dropped every record of the day it named but a midnight one. +RSpec.describe Filterable::Concerns::Datable, 'on a datetime column' do + let!(:morning) { MovementDetail.create!(quoted_at: Time.utc(2026, 3, 1, 9, 0)) } + let!(:evening) { MovementDetail.create!(quoted_at: Time.utc(2026, 3, 1, 17, 30)) } + let!(:next_day) { MovementDetail.create!(quoted_at: Time.utc(2026, 3, 2, 9, 0)) } + + def filter(bounds) + MovementDetail.where.not(quoted_at: nil).filterable(filters: { quoted_at: bounds }) + end + + describe 'a bound given as a bare day names the whole day' do + it 'keeps every record of the day under a since ceiling' do + expect(filter(since: '2026-03-01')).to contain_exactly(morning, evening) + end + + it 'keeps every record of the day under a from/to range' do + expect(filter(from: '2026-03-01', to: '2026-03-01')).to contain_exactly(morning, evening) + end + + it 'excludes the whole day under a before floor' do + expect(filter(before: '2026-03-02')).to contain_exactly(morning, evening) + end + + it 'excludes the whole day under an after floor' do + expect(filter(after: '2026-03-01')).to contain_exactly(next_day) + end + end + + describe 'a bound carrying an hour is honoured to the second' do + it 'cuts inside the day on since' do + expect(filter(since: '2026-03-01T12:00:00Z')).to contain_exactly(morning) + end + + it 'cuts inside the day on after' do + expect(filter(after: '2026-03-01T12:00:00Z')).to contain_exactly(evening, next_day) + end + + it 'cuts inside the day on before' do + expect(filter(before: '2026-03-01T12:00:00Z')).to contain_exactly(morning) + end + + it 'cuts inside the day on a range' do + expect(filter(from: '2026-03-01T12:00:00Z', to: '2026-03-02T12:00:00Z')) + .to contain_exactly(evening, next_day) + end + end + + describe 'a date column is left as it was' do + let!(:january) { MovementDetail.create!(value_date: Date.new(2026, 1, 1)) } + let!(:february) { MovementDetail.create!(value_date: Date.new(2026, 2, 1)) } + + it 'keeps the bound inclusive on since' do + result = MovementDetail.where.not(value_date: nil).filterable(filters: { value_date: { since: '2026-02-01' } }) + + expect(result).to contain_exactly(january, february) + end + + it 'keeps the bound exclusive on after' do + result = MovementDetail.where.not(value_date: nil).filterable(filters: { value_date: { after: '2026-01-01' } }) + + expect(result).to contain_exactly(february) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c1e712f..fa1b524 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -63,7 +63,7 @@ class MovementDetail < ActiveRecord::Base scope :cheaper_than, ->(cents) { where(gross_amount_cents: ...cents) } scope :costlier_than, ->(cents) { where(gross_amount_cents: cents..) } - datable :value_date, :booking_date, account_opened: { account: :opened_on } + datable :value_date, :booking_date, :quoted_at, account_opened: { account: :opened_on } sortable :value_date, :gross_amount_cents equatable :reference, :gross_amount_cents, account_name: { account: :name }, bank_name: { account: { bank: :name } }, diff --git a/spec/support/schema.rb b/spec/support/schema.rb index ed41504..6d77b4a 100644 --- a/spec/support/schema.rb +++ b/spec/support/schema.rb @@ -26,6 +26,7 @@ def define create_table :movement_details, force: true do |t| t.date :value_date t.date :booking_date + t.datetime :quoted_at t.integer :gross_amount_cents t.string :reference t.integer :account_id From 4fc967ee014baeaa7a5390461babfc513d583088 Mon Sep 17 00:00:00 2001 From: Memli-Sheremeti Date: Wed, 2 Sep 2026 14:22:36 +0200 Subject: [PATCH 2/3] style(datable): retirer les commentaires du correctif --- lib/filterable/datable.rb | 38 --------------------------------- lib/filterable/datable/range.rb | 10 --------- spec/datable_datetime_spec.rb | 3 --- 3 files changed, 51 deletions(-) diff --git a/lib/filterable/datable.rb b/lib/filterable/datable.rb index cb562bf..9dea43b 100644 --- a/lib/filterable/datable.rb +++ b/lib/filterable/datable.rb @@ -26,13 +26,6 @@ def parse(value) BARE_DAY = /\A\d{4}-\d{2}-\d{2}\z/ - # Whether a raw bound names a day and no hour. +DateTime+ is checked ahead of - # +Date+ because it inherits from it, so an instant would otherwise be taken - # for a bare day and widened to the whole of it. - # - # @api private - # @param value [Date, Time, String] the raw bound from the params. - # @return [Boolean] def bare_day?(value) case value when nil, DateTime, Time then false @@ -41,12 +34,6 @@ def bare_day?(value) end end - # The column type a declared target resolves to, on the model it belongs to. - # - # @api private - # @param scope [ActiveRecord::Relation] the relation being filtered. - # @param target [Symbol, String, Hash] the declared target. - # @return [Symbol, nil] e.g. +:date+ or +:datetime+; nil when unresolvable. def column_type(scope, target) klass, column = if target.is_a?(Hash) @@ -61,47 +48,22 @@ def column_type(scope, target) klass&.type_for_attribute(column.to_s)&.type end - # Whether a bound should be read as naming a whole day: a bare day asked of a - # column that holds an instant. On a date column, or for a bound carrying an - # hour, the comparison stays exactly what it was. - # - # @api private - # @param scope [ActiveRecord::Relation] the relation being filtered. - # @param target [Symbol, String, Hash] the declared target. - # @param value [Date, Time, String] the raw bound from the params. - # @return [Boolean] def whole_day?(scope, target, value) bare_day?(value) && column_type(scope, target) == :datetime end - # The first instant of the day a value names. - # - # @api private - # @return [Time, ActiveSupport::TimeWithZone] def day_start(value) midnight(to_day(value)) end - # The first instant of the day after the one a value names — the exclusive - # edge of that day. Built from the next day rather than by adding 24 hours, - # which a DST change makes wrong. - # - # @api private - # @return [Time, ActiveSupport::TimeWithZone] def day_end(value) midnight(to_day(value) + 1) end - # @api private - # @return [Date] def to_day(value) value.is_a?(Date) ? value.to_date : Date.parse(value.to_s) end - # Midnight opening a day, in the application zone when there is one. - # - # @api private - # @return [Time, ActiveSupport::TimeWithZone] def midnight(day) Time.zone ? day.in_time_zone : day.to_time end diff --git a/lib/filterable/datable/range.rb b/lib/filterable/datable/range.rb index 14c8a50..631e686 100644 --- a/lib/filterable/datable/range.rb +++ b/lib/filterable/datable/range.rb @@ -24,22 +24,12 @@ def call(params, scope) end end - # The inclusive floor: the day's first instant when +from+ names a whole day, - # the parsed bound otherwise. - # - # @api private - # @return [Date, Time] def floor_for(scope, target, raw, parsed) return parsed unless Filterable::Datable.whole_day?(scope, target, raw) Filterable::Datable.day_start(raw) end - # The inclusive ceiling: a whole day is admitted up to the next midnight, - # excluded, so every instant of the day it names is kept. - # - # @api private - # @return [Arel::Nodes::Node] def ceiling_for(field, scope, target, raw, parsed) return field.lteq(parsed) unless Filterable::Datable.whole_day?(scope, target, raw) diff --git a/spec/datable_datetime_spec.rb b/spec/datable_datetime_spec.rb index abea63b..673091a 100644 --- a/spec/datable_datetime_spec.rb +++ b/spec/datable_datetime_spec.rb @@ -2,9 +2,6 @@ require 'spec_helper' -# A datable column holding an instant is still asked about in days: a consumer sending -# `since=2026-03-01` means that day, not the midnight opening it. Read as an instant, -# such a bound dropped every record of the day it named but a midnight one. RSpec.describe Filterable::Concerns::Datable, 'on a datetime column' do let!(:morning) { MovementDetail.create!(quoted_at: Time.utc(2026, 3, 1, 9, 0)) } let!(:evening) { MovementDetail.create!(quoted_at: Time.utc(2026, 3, 1, 17, 30)) } From 938fe116ebce8a26aa227f0b2f89d58708df5a1c Mon Sep 17 00:00:00 2001 From: Memli-Sheremeti Date: Wed, 2 Sep 2026 15:54:57 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(datable):=20borner=20la=20journ=C3=A9e?= =?UTF-8?q?=20au=20parsing=20plut=C3=B4t=20qu'au=20filtre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La détection par expression régulière et introspection du type de colonne ne voyait pas le filtre before, ne reconnaissait que le format ISO, et coupait la journée dans le fuseau du serveur alors que les bornes horaires étaient lues dans celui de l'application. parse rend désormais une Date pour un jour et un Time pour un instant, via Date._parse. Chaque filtre porte un prédicat nommé d'après sa sémantique. --- README.md | 5 ++- lib/filterable/datable.rb | 66 ++++++++++++-------------------- lib/filterable/datable/after.rb | 9 +---- lib/filterable/datable/before.rb | 2 +- lib/filterable/datable/range.rb | 18 ++------- lib/filterable/datable/since.rb | 9 +---- spec/datable_datetime_spec.rb | 20 ++++++++++ 7 files changed, 56 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 57f058b..534577e 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,9 @@ MovementDetail.filterable(filters: { quoted_at: { since: '2026-03-01T12:00:00Z' # => the 09:00 row only ``` -Days are bounded at midnight in `Time.zone` when the application sets one, and -in the system zone otherwise. On a `t.date` column nothing changes. +Which day an instant belongs to is decided in `Time.zone` when the application +sets one, and in the system zone otherwise, so the same bound answers the same +way whatever zone the server runs in. On a `t.date` column nothing changes. ### Equality filters (`Equatable`) diff --git a/lib/filterable/datable.rb b/lib/filterable/datable.rb index 9dea43b..922dd2a 100644 --- a/lib/filterable/datable.rb +++ b/lib/filterable/datable.rb @@ -5,67 +5,51 @@ module Filterable module Datable module_function - # Coerce a raw filter value into a Date/Time, or nil when it cannot be parsed, - # so a malformed query param narrows nothing instead of raising. + # Coerce a raw filter value into the bound it names: a Date when it names a + # whole day, a Time when it names an instant, or nil when it cannot be + # parsed, so a malformed query param narrows nothing instead of raising. # # @api private # @param value [Date, Time, String] the raw bound from the params. - # @return [Date, Time, nil] the parsed value, or nil when unparseable. + # @return [Date, Time, nil] the parsed bound, or nil when unparseable. def parse(value) case value - when Date, Time - value - else - begin - Time.parse(value.to_s) - rescue ArgumentError - nil - end + when DateTime then value.to_time + when Time, Date then value + else parse_bound(value.to_s) end end - BARE_DAY = /\A\d{4}-\d{2}-\d{2}\z/ - - def bare_day?(value) - case value - when nil, DateTime, Time then false - when Date then true - else value.to_s.match?(BARE_DAY) - end + def parse_bound(string) + parts = Date._parse(string) + day = Date.new(parts[:year], parts[:mon], parts[:mday]) + parts.key?(:hour) ? (Time.zone || Time).parse(string) : day + rescue TypeError, ArgumentError + nil end - def column_type(scope, target) - klass, column = - if target.is_a?(Hash) - path, name = Filterable::Target.unpack(target) - return if name.is_a?(Hash) || path.empty? - - [Filterable::Target.resolve(scope.klass, path)&.[](:klass), name] - else - [scope.klass, target] - end - - klass&.type_for_attribute(column.to_s)&.type + def whole_day?(bound) + bound.instance_of?(Date) end - def whole_day?(scope, target, value) - bare_day?(value) && column_type(scope, target) == :datetime + def midnight(day) + Time.zone ? day.in_time_zone : day.to_time end - def day_start(value) - midnight(to_day(value)) + def on_or_before(field, bound) + whole_day?(bound) ? field.lt(midnight(bound + 1)) : field.lteq(bound) end - def day_end(value) - midnight(to_day(value) + 1) + def strictly_before(field, bound) + field.lt(whole_day?(bound) ? midnight(bound) : bound) end - def to_day(value) - value.is_a?(Date) ? value.to_date : Date.parse(value.to_s) + def on_or_after(field, bound) + field.gteq(whole_day?(bound) ? midnight(bound) : bound) end - def midnight(day) - Time.zone ? day.in_time_zone : day.to_time + def strictly_after(field, bound) + whole_day?(bound) ? field.gteq(midnight(bound + 1)) : field.gt(bound) end # Whitelist the params to the declared datable attributes, keeping their diff --git a/lib/filterable/datable/after.rb b/lib/filterable/datable/after.rb index 43e1174..c3903e4 100644 --- a/lib/filterable/datable/after.rb +++ b/lib/filterable/datable/after.rb @@ -15,13 +15,8 @@ module After # @return [ActiveRecord::Relation] the narrowed relation. def call(params, scope) entries = Filterable::Datable.accepted(params, scope, :after) - entries.reduce(scope) do |sub_scope, (_name, target, bounds, parsed)| - if Filterable::Datable.whole_day?(sub_scope, target, bounds[:after]) - edge = Filterable::Datable.day_end(bounds[:after]) - Filterable::Target.narrow(sub_scope, target) { |field| field.gteq(edge) } - else - Filterable::Target.narrow(sub_scope, target) { |field| field.gt(parsed[:after]) } - end + entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)| + Filterable::Target.narrow(sub_scope, target) { |field| Filterable::Datable.strictly_after(field, parsed[:after]) } end end diff --git a/lib/filterable/datable/before.rb b/lib/filterable/datable/before.rb index 89b1c20..9e978f7 100644 --- a/lib/filterable/datable/before.rb +++ b/lib/filterable/datable/before.rb @@ -16,7 +16,7 @@ module Before def call(params, scope) entries = Filterable::Datable.accepted(params, scope, :before) entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)| - Filterable::Target.narrow(sub_scope, target) { |field| field.lt(parsed[:before]) } + Filterable::Target.narrow(sub_scope, target) { |field| Filterable::Datable.strictly_before(field, parsed[:before]) } end end diff --git a/lib/filterable/datable/range.rb b/lib/filterable/datable/range.rb index 631e686..754edcf 100644 --- a/lib/filterable/datable/range.rb +++ b/lib/filterable/datable/range.rb @@ -16,26 +16,14 @@ module Range # @return [ActiveRecord::Relation] the narrowed relation. def call(params, scope) entries = Filterable::Datable.accepted(params, scope, :from, :to) - entries.reduce(scope) do |sub_scope, (_name, target, bounds, parsed)| - floor = floor_for(sub_scope, target, bounds[:from], parsed[:from]) + entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)| Filterable::Target.narrow(sub_scope, target) do |field| - field.gteq(floor).and(ceiling_for(field, sub_scope, target, bounds[:to], parsed[:to])) + Filterable::Datable.on_or_after(field, parsed[:from]) + .and(Filterable::Datable.on_or_before(field, parsed[:to])) end end end - def floor_for(scope, target, raw, parsed) - return parsed unless Filterable::Datable.whole_day?(scope, target, raw) - - Filterable::Datable.day_start(raw) - end - - def ceiling_for(field, scope, target, raw, parsed) - return field.lteq(parsed) unless Filterable::Datable.whole_day?(scope, target, raw) - - field.lt(Filterable::Datable.day_end(raw)) - end - # The bounds {call} would apply, keyed by public name, with their raw values. # # @api private diff --git a/lib/filterable/datable/since.rb b/lib/filterable/datable/since.rb index c0e8f8a..abff9a3 100644 --- a/lib/filterable/datable/since.rb +++ b/lib/filterable/datable/since.rb @@ -15,13 +15,8 @@ module Since # @return [ActiveRecord::Relation] the narrowed relation. def call(params, scope) entries = Filterable::Datable.accepted(params, scope, :since) - entries.reduce(scope) do |sub_scope, (_name, target, bounds, parsed)| - if Filterable::Datable.whole_day?(sub_scope, target, bounds[:since]) - edge = Filterable::Datable.day_end(bounds[:since]) - Filterable::Target.narrow(sub_scope, target) { |field| field.lt(edge) } - else - Filterable::Target.narrow(sub_scope, target) { |field| field.lteq(parsed[:since]) } - end + entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)| + Filterable::Target.narrow(sub_scope, target) { |field| Filterable::Datable.on_or_before(field, parsed[:since]) } end end diff --git a/spec/datable_datetime_spec.rb b/spec/datable_datetime_spec.rb index 673091a..07d0c06 100644 --- a/spec/datable_datetime_spec.rb +++ b/spec/datable_datetime_spec.rb @@ -24,6 +24,10 @@ def filter(bounds) expect(filter(before: '2026-03-02')).to contain_exactly(morning, evening) end + it 'keeps nothing of the day it floors out' do + expect(filter(before: '2026-03-01')).to be_empty + end + it 'excludes the whole day under an after floor' do expect(filter(after: '2026-03-01')).to contain_exactly(next_day) end @@ -48,6 +52,22 @@ def filter(bounds) end end + describe 'the day is cut in the application zone' do + let!(:paris_next_day) { MovementDetail.create!(quoted_at: Time.utc(2026, 3, 1, 23, 30)) } + + around do |example| + Time.use_zone('Europe/Paris') { example.run } + end + + it 'leaves out an instant that already belongs to the next day there' do + expect(filter(since: '2026-03-01')).to contain_exactly(morning, evening) + end + + it 'keeps it under the day it belongs to there' do + expect(filter(from: '2026-03-02', to: '2026-03-02')).to contain_exactly(next_day, paris_next_day) + end + end + describe 'a date column is left as it was' do let!(:january) { MovementDetail.create!(value_date: Date.new(2026, 1, 1)) } let!(:february) { MovementDetail.create!(value_date: Date.new(2026, 2, 1)) }