From ac2cd4ada85cf59cd2da8e41b401a3257c898708 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Fri, 14 Aug 2026 14:49:52 +0930 Subject: [PATCH] improvement: support the Duration form of ago, from_now, datetime_add and date_add `Ash.Query.Function.Ago`, `FromNow`, `DateTimeAdd` and `DateAdd` each accept a `Duration` as well as an integer/interval-name pair, and evaluate it in Elixir, but only the interval form was rendered here. The Duration form reached `default_dynamic_expr/6`, matched nothing, and raised `Unsupported expression`, so it worked on data layers that evaluate expressions at runtime and failed on every SQL-backed one. Adds a clause per function, rendering the duration as an interval parameter. Ecto has a native `:duration` type and Postgres accepts a `%Duration{}` directly, so no interval-name string building is needed, and multi-unit durations work. The datetime operand is cast, as Ecto's own `datetime_add/3` does with `type_unless_typed`; without it Postgres resolves `? - ?::interval` as interval arithmetic. `date_add` casts back to `::date` for the same reason Ecto's does. `from_now/1` is uncallable until the corresponding ash change releases, so its clause is untestable downstream until then. --- lib/expr.ex | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/lib/expr.ex b/lib/expr.ex index 1b9ad65..9c3203c 100644 --- a/lib/expr.ex +++ b/lib/expr.ex @@ -305,6 +305,27 @@ defmodule AshSql.Expr do ), acc} end + defp default_dynamic_expr( + query, + %Ago{arguments: [duration], embedded?: pred_embedded?}, + bindings, + embedded?, + acc, + _type + ) do + {duration, acc} = + do_dynamic_expr( + query, + duration, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc, + :duration + ) + + {Ecto.Query.dynamic(fragment("(?::timestamp - ?)", ^DateTime.utc_now(), ^duration)), acc} + end + defp default_dynamic_expr( query, %StartOfDay{arguments: [value], embedded?: pred_embedded?}, @@ -446,6 +467,27 @@ defmodule AshSql.Expr do ), acc} end + defp default_dynamic_expr( + query, + %FromNow{arguments: [duration], embedded?: pred_embedded?}, + bindings, + embedded?, + acc, + _type + ) do + {duration, acc} = + do_dynamic_expr( + query, + duration, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc, + :duration + ) + + {Ecto.Query.dynamic(fragment("(?::timestamp + ?)", ^DateTime.utc_now(), ^duration)), acc} + end + defp default_dynamic_expr( query, %DateTimeAdd{arguments: [datetime, amount, interval], embedded?: pred_embedded?}, @@ -478,6 +520,36 @@ defmodule AshSql.Expr do acc} end + defp default_dynamic_expr( + query, + %DateTimeAdd{arguments: [datetime, duration], embedded?: pred_embedded?}, + bindings, + embedded?, + acc, + _type + ) do + {datetime, acc} = + do_dynamic_expr( + query, + datetime, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc + ) + + {duration, acc} = + do_dynamic_expr( + query, + duration, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc, + :duration + ) + + {Ecto.Query.dynamic(fragment("(?::timestamp + ?)", ^datetime, ^duration)), acc} + end + defp default_dynamic_expr( query, %DateAdd{arguments: [date, amount, interval], embedded?: pred_embedded?}, @@ -509,6 +581,38 @@ defmodule AshSql.Expr do {Ecto.Query.dynamic(fragment("(?)", date_add(^date, ^amount, ^to_string(interval)))), acc} end + defp default_dynamic_expr( + query, + %DateAdd{arguments: [date, duration], embedded?: pred_embedded?}, + bindings, + embedded?, + acc, + _type + ) do + {date, acc} = + do_dynamic_expr( + query, + date, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc + ) + + {duration, acc} = + do_dynamic_expr( + query, + duration, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc, + :duration + ) + + # `date + interval` is a timestamp in Postgres, so cast back, as Ecto's own + # `date_add/3` does. + {Ecto.Query.dynamic(fragment("((?::date + ?)::date)", ^date, ^duration)), acc} + end + defp default_dynamic_expr( query, %GetPath{