Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[<attribute>]` directly: a
Expand Down
50 changes: 39 additions & 11 deletions lib/filterable/datable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/filterable/datable/after.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/filterable/datable/before.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion lib/filterable/datable/range.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/filterable/datable/since.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 87 additions & 0 deletions spec/datable_datetime_spec.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
1 change: 1 addition & 0 deletions spec/support/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down