diff --git a/README.md b/README.md index 7f3dbd1..534577e 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,33 @@ 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 +``` + +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`) Each declared equatable attribute reads `filters[]` directly: a diff --git a/lib/filterable/datable.rb b/lib/filterable/datable.rb index 62dab3e..922dd2a 100644 --- a/lib/filterable/datable.rb +++ b/lib/filterable/datable.rb @@ -5,25 +5,53 @@ 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 + 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 whole_day?(bound) + bound.instance_of?(Date) + end + + def midnight(day) + Time.zone ? day.in_time_zone : day.to_time + end + + def on_or_before(field, bound) + whole_day?(bound) ? field.lt(midnight(bound + 1)) : field.lteq(bound) + end + + def strictly_before(field, bound) + field.lt(whole_day?(bound) ? midnight(bound) : bound) + end + + def on_or_after(field, bound) + field.gteq(whole_day?(bound) ? midnight(bound) : bound) + end + + 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 # 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..c3903e4 100644 --- a/lib/filterable/datable/after.rb +++ b/lib/filterable/datable/after.rb @@ -16,7 +16,7 @@ module After 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]) } + 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 b8a3fe9..754edcf 100644 --- a/lib/filterable/datable/range.rb +++ b/lib/filterable/datable/range.rb @@ -18,7 +18,8 @@ def call(params, scope) entries = Filterable::Datable.accepted(params, scope, :from, :to) entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)| Filterable::Target.narrow(sub_scope, target) do |field| - field.gteq(parsed[:from]).and(field.lteq(parsed[:to])) + Filterable::Datable.on_or_after(field, parsed[:from]) + .and(Filterable::Datable.on_or_before(field, parsed[:to])) end end end diff --git a/lib/filterable/datable/since.rb b/lib/filterable/datable/since.rb index ded420e..abff9a3 100644 --- a/lib/filterable/datable/since.rb +++ b/lib/filterable/datable/since.rb @@ -16,7 +16,7 @@ module Since 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]) } + 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 new file mode 100644 index 0000000..07d0c06 --- /dev/null +++ b/spec/datable_datetime_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +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 '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 + 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 '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)) } + + 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