Skip to content

Commit f3a27d9

Browse files
feat: ask whether a range holds a point or a range, or meets another (ash-project#2870)
1 parent 2724f28 commit f3a27d9

3 files changed

Lines changed: 217 additions & 22 deletions

File tree

lib/ash/query/function/range_overlaps.ex

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,34 +22,15 @@ defmodule Ash.Query.Function.RangeOverlaps do
2222
def evaluate(%{arguments: [nil, _]}), do: {:known, nil}
2323
def evaluate(%{arguments: [_, nil]}), do: {:known, nil}
2424

25+
# Overlap is a fact about two ranges, so `Ash.Range` answers it. Postgres `&&` is
26+
# intersection, which is what `intersects?/2` is named for.
2527
def evaluate(%{arguments: [%Range{} = left, %Range{} = right]}) do
26-
{:known, do_overlap?(left, right)}
28+
{:known, Range.intersects?(left, right)}
2729
end
2830

2931
def evaluate(_other), do: :unknown
3032

3133
def can_return_nil?(%{arguments: arguments}) do
3234
Enum.any?(arguments, &Ash.Expr.can_return_nil?/1)
3335
end
34-
35-
# Two ranges overlap iff neither lies entirely on one side of the other, accounting for
36-
# each bound's inclusivity (`[`/`]` inclusive, `(`/`)` exclusive) and treating a `nil`
37-
# bound as ±∞. Empty ranges (e.g. `[5, 5)`) overlap nothing. Matches Postgres `&&`.
38-
defp do_overlap?(left, right) do
39-
not Range.empty?(left) and not Range.empty?(right) and
40-
not separated?(left, right) and not separated?(right, left)
41-
end
42-
43-
# `x` lies entirely at/below `y`, sharing no point at the `x.upper` / `y.lower` seam.
44-
defp separated?(%Range{upper: nil}, _y), do: false
45-
defp separated?(_x, %Range{lower: nil}), do: false
46-
47-
defp separated?(%Range{upper: xu, bounds: xb}, %Range{lower: yl, bounds: yb}) do
48-
cond do
49-
Comp.less_than?(xu, yl) -> true
50-
# Touching boundary: a shared point only if BOTH sides include it.
51-
Comp.equal?(xu, yl) -> not (Range.upper_inclusive?(xb) and Range.lower_inclusive?(yb))
52-
true -> false
53-
end
54-
end
5536
end

lib/ash/range.ex

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,78 @@ defmodule Ash.Range do
6868
@spec upper_inclusive?(bounds()) :: boolean()
6969
def upper_inclusive?(bounds), do: bounds in [:"(]", :"[]"]
7070

71+
@doc """
72+
Whether the range holds `value`, which may be a point or another range.
73+
74+
An unbounded end holds everything beyond it, and an empty range holds no point.
75+
Each bound is compared with `Comp`, so an inner type behaves inside a range as
76+
it does outside one, and a bound that excludes its own value (`(` or `)`) is
77+
not held.
78+
79+
A range holds another when the second lies within the first, sharing an endpoint
80+
or being equal included. Every range holds the empty range, as Postgres `@>` does.
81+
"""
82+
@spec contains?(t(), term()) :: boolean()
83+
def contains?(%__MODULE__{} = range, %__MODULE__{} = value) do
84+
empty?(value) or
85+
(not empty?(range) and
86+
relation(range, value) in [:contains, :started_by, :finished_by, :equals])
87+
end
88+
89+
def contains?(%__MODULE__{} = range, value) do
90+
not empty?(range) and above_lower?(range, value) and below_upper?(range, value)
91+
end
92+
93+
defp above_lower?(%{lower: nil}, _value), do: true
94+
95+
defp above_lower?(range, value) do
96+
case Comp.compare(value, range.lower) do
97+
:gt -> true
98+
:eq -> lower_inclusive?(range.bounds)
99+
:lt -> false
100+
end
101+
end
102+
103+
defp below_upper?(%{upper: nil}, _value), do: true
104+
105+
defp below_upper?(range, value) do
106+
case Comp.compare(value, range.upper) do
107+
:lt -> true
108+
:eq -> upper_inclusive?(range.bounds)
109+
:gt -> false
110+
end
111+
end
112+
113+
@doc """
114+
Whether two ranges share any point.
115+
116+
An empty range intersects nothing, not even itself. Each range must start at or
117+
before the other ends, and a boundary the two ranges share counts only when both
118+
sides include it — so `[1,3)` and `[3,5)` do not intersect, where `[1,3]` and
119+
`[3,5)` do. Bounds are compared with `Comp`, as everywhere else here.
120+
121+
Named for what it answers rather than for the operator it backs. Postgres calls
122+
`&&` "overlap" and `range_overlaps/2` keeps that name, but Allen's *overlaps* is
123+
the narrower relation where two ranges cross with neither containing the other —
124+
under which `[1,10)` and `[3,5)` do **not** overlap. This returns true for them.
125+
"""
126+
@spec intersects?(t(), t()) :: boolean()
127+
def intersects?(%__MODULE__{} = left, %__MODULE__{} = right) do
128+
not empty?(left) and not empty?(right) and
129+
starts_before_end?(left, right) and starts_before_end?(right, left)
130+
end
131+
132+
defp starts_before_end?(%{lower: nil}, _other), do: true
133+
defp starts_before_end?(_range, %{upper: nil}), do: true
134+
135+
defp starts_before_end?(range, other) do
136+
case Comp.compare(range.lower, other.upper) do
137+
:lt -> true
138+
:eq -> lower_inclusive?(range.bounds) and upper_inclusive?(other.bounds)
139+
:gt -> false
140+
end
141+
end
142+
71143
@doc """
72144
Compares two ranges as Postgres orders them: empty first, then by lower bound, then
73145
by upper, an unbounded end as `-∞`/`+∞`, and the earlier boundary first where two

test/type/range_test.exs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,4 +530,146 @@ defmodule Ash.Type.RangeTest do
530530
refute Range.empty?(%Range{lower: nil, upper: nil})
531531
end
532532
end
533+
534+
describe "contains?/2" do
535+
test "a bounded range holds values between its bounds" do
536+
range = %Ash.Range{lower: 1, upper: 5, bounds: :"[)"}
537+
538+
refute Ash.Range.contains?(range, 0)
539+
assert Ash.Range.contains?(range, 1)
540+
assert Ash.Range.contains?(range, 4)
541+
refute Ash.Range.contains?(range, 5)
542+
end
543+
544+
test "a bound holds its own value only when inclusive" do
545+
assert Ash.Range.contains?(%Ash.Range{lower: 1, upper: 5, bounds: :"[]"}, 5)
546+
refute Ash.Range.contains?(%Ash.Range{lower: 1, upper: 5, bounds: :"()"}, 1)
547+
assert Ash.Range.contains?(%Ash.Range{lower: 1, upper: 5, bounds: :"(]"}, 5)
548+
refute Ash.Range.contains?(%Ash.Range{lower: 1, upper: 5, bounds: :"(]"}, 1)
549+
end
550+
551+
test "an unbounded end holds everything beyond it" do
552+
assert Ash.Range.contains?(%Ash.Range{lower: nil, upper: 5}, -1_000)
553+
assert Ash.Range.contains?(%Ash.Range{lower: 1, upper: nil}, 1_000)
554+
assert Ash.Range.contains?(%Ash.Range{lower: nil, upper: nil}, 0)
555+
end
556+
557+
test "an empty range holds nothing" do
558+
refute Ash.Range.contains?(%Ash.Range{lower: 5, upper: 5, bounds: :"[)"}, 5)
559+
refute Ash.Range.contains?(%Ash.Range{lower: 9, upper: 5}, 7)
560+
end
561+
562+
test "holds datetimes by Comp, not by term order" do
563+
range = %Ash.Range{
564+
lower: ~U[2026-01-31 00:00:00Z],
565+
upper: ~U[2026-03-01 00:00:00Z],
566+
bounds: :"[)"
567+
}
568+
569+
assert Ash.Range.contains?(range, ~U[2026-02-01 00:00:00Z])
570+
refute Ash.Range.contains?(range, ~U[2026-03-02 00:00:00Z])
571+
end
572+
end
573+
574+
describe "contains?/2 with a range" do
575+
# Cross-checked against Postgres 19: `@>` on int4range agrees case for case.
576+
test "a range holds one that lies within it, sharing an endpoint or equal included" do
577+
outer = %Range{lower: 1, upper: 10, bounds: :"[)"}
578+
579+
assert Range.contains?(outer, %Range{lower: 3, upper: 5, bounds: :"[)"})
580+
assert Range.contains?(outer, %Range{lower: 1, upper: 5, bounds: :"[)"})
581+
assert Range.contains?(outer, %Range{lower: 5, upper: 10, bounds: :"[)"})
582+
assert Range.contains?(outer, outer)
583+
refute Range.contains?(outer, %Range{lower: 5, upper: 20, bounds: :"[)"})
584+
end
585+
586+
test "every range holds the empty range, and the empty range holds nothing else" do
587+
empty = Range.empty()
588+
range = %Range{lower: 1, upper: 10, bounds: :"[)"}
589+
590+
assert Range.contains?(range, empty)
591+
assert Range.contains?(empty, empty)
592+
refute Range.contains?(empty, range)
593+
refute Range.contains?(empty, 5)
594+
end
595+
596+
test "an unbounded end holds everything beyond it" do
597+
assert Range.contains?(
598+
%Range{lower: nil, upper: nil, bounds: :"[)"},
599+
%Range{lower: 1, upper: 10, bounds: :"[)"}
600+
)
601+
602+
refute Range.contains?(
603+
%Range{lower: 1, upper: 10, bounds: :"[)"},
604+
%Range{lower: nil, upper: 10, bounds: :"[)"}
605+
)
606+
end
607+
end
608+
609+
describe "intersects?/2" do
610+
test "two ranges sharing points intersect, in either order" do
611+
left = %Range{lower: 1, upper: 5, bounds: :"[)"}
612+
right = %Range{lower: 4, upper: 9, bounds: :"[)"}
613+
614+
assert Range.intersects?(left, right)
615+
assert Range.intersects?(right, left)
616+
end
617+
618+
test "a range contained in another intersects it" do
619+
assert Range.intersects?(
620+
%Range{lower: 1, upper: 9, bounds: :"[)"},
621+
%Range{lower: 3, upper: 4, bounds: :"[)"}
622+
)
623+
end
624+
625+
test "adjacent ranges do not intersect, which is what lets them tile" do
626+
refute Range.intersects?(
627+
%Range{lower: 1, upper: 3, bounds: :"[)"},
628+
%Range{lower: 3, upper: 5, bounds: :"[)"}
629+
)
630+
end
631+
632+
test "a shared boundary is a shared point only when both sides include it" do
633+
assert Range.intersects?(
634+
%Range{lower: 1, upper: 3, bounds: :"[]"},
635+
%Range{lower: 3, upper: 5, bounds: :"[)"}
636+
)
637+
638+
refute Range.intersects?(
639+
%Range{lower: 1, upper: 3, bounds: :"[]"},
640+
%Range{lower: 3, upper: 5, bounds: :"()"}
641+
)
642+
end
643+
644+
test "an unbounded end intersects everything beyond it" do
645+
assert Range.intersects?(
646+
%Range{lower: 1, upper: nil, bounds: :"[)"},
647+
%Range{lower: 1_000, upper: nil, bounds: :"[)"}
648+
)
649+
650+
assert Range.intersects?(
651+
%Range{lower: nil, upper: nil, bounds: :"[)"},
652+
%Range{lower: 3, upper: 4, bounds: :"[)"}
653+
)
654+
end
655+
656+
test "an empty range intersects nothing, not even itself" do
657+
empty = %Range{lower: 5, upper: 5, bounds: :"[)"}
658+
659+
refute Range.intersects?(empty, empty)
660+
refute Range.intersects?(empty, %Range{lower: nil, upper: nil, bounds: :"[)"})
661+
end
662+
663+
test "compares datetimes by Comp, not by term order" do
664+
assert Range.intersects?(
665+
%Range{lower: ~U[2026-01-31 00:00:00Z], upper: nil, bounds: :"[)"},
666+
%Range{lower: ~U[2026-02-01 00:00:00Z], upper: nil, bounds: :"[)"}
667+
)
668+
669+
refute Range.intersects?(
670+
%Range{lower: ~U[2026-01-31 00:00:00Z], upper: ~U[2026-02-01 00:00:00Z]},
671+
%Range{lower: ~U[2026-02-01 00:00:00Z], upper: ~U[2026-03-01 00:00:00Z]}
672+
)
673+
end
674+
end
533675
end

0 commit comments

Comments
 (0)