diff --git a/lib/expr.ex b/lib/expr.ex index c1c19d1..4dc9382 100644 --- a/lib/expr.ex +++ b/lib/expr.ex @@ -1825,314 +1825,18 @@ defmodule AshSql.Expr do defp default_dynamic_expr( query, - %mod{ + %_{ __predicate__?: _, - left: left, - right: right, - embedded?: pred_embedded?, - operator: operator - }, + left: _, + right: _, + operator: _ + } = op, bindings, embedded?, acc, type ) do - {[left_type, right_type], type} = - case operator do - :/ -> - {types, result} = determine_types(bindings.sql_behaviour, mod, [left, right], type) - - {types, result} = - {Enum.map(types, fn - {Ash.Type.Float, _} -> {Ash.Type.Decimal, []} - other -> other - end), - case result do - {Ash.Type.Float, _} -> {Ash.Type.Decimal, []} - other -> other - end} - - case result do - {Ash.Type.Decimal, _} -> - {Enum.map(types, fn _ -> {Ash.Type.Decimal, []} end), result} - - _ -> - {types, result} - end - - _ -> - determine_types(bindings.sql_behaviour, mod, [left, right], type) - end - - bindings = - if no_cast_for_native_value?(left, left_type) or - no_cast_for_native_value?(right, right_type) do - Map.put(bindings, :skip_cast_for_ref?, true) - else - bindings - end - - {left_expr, acc} = - if left_type do - maybe_type_expr( - query, - left, - set_location(bindings, :sub_expr), - pred_embedded? || embedded?, - acc, - left_type - ) - else - do_dynamic_expr( - query, - left, - set_location(bindings, :sub_expr), - pred_embedded? || embedded?, - acc, - left_type - ) - end - - with :in <- operator, - {:ok, item_type} <- extract_multidimensional_array_type(right_type), - {:ok, right} <- extract_list_value(right) do - Enum.reduce(right, {nil, acc}, fn item, {expr, acc} -> - {elem_expr, acc} = - do_dynamic_expr( - query, - item, - set_location(bindings, :sub_expr), - pred_embedded? || embedded?, - acc, - item_type - ) - - if is_nil(expr) do - {Ecto.Query.dynamic(^left_expr == ^elem_expr), acc} - else - {Ecto.Query.dynamic(^expr or ^left_expr == ^elem_expr), acc} - end - end) - else - _ -> - if operator == :in do - get_path = strip_get_path_type(right) - - if match?(%Ash.Query.Function.GetPath{}, get_path) and get_path_array_type?(right_type) do - context_embedded? = pred_embedded? || embedded? - - {raw_right_expr, acc} = - get_untyped_get_path_expr( - query, - get_path, - bindings, - context_embedded?, - acc - ) - - {Ecto.Query.dynamic(fragment("(?::jsonb \\? ?)", ^raw_right_expr, ^left_expr)), acc} - else - {right_expr, acc} = - evaluate_right( - query, - right, - bindings, - pred_embedded? || embedded?, - acc, - right_type - ) - - {Ecto.Query.dynamic(^left_expr in ^right_expr), acc} - end - else - {right_expr, acc} = - evaluate_right( - query, - right, - bindings, - pred_embedded? || embedded?, - acc, - right_type - ) - - case operator do - :== -> - {Ecto.Query.dynamic(^left_expr == ^right_expr), acc} - - :!= -> - {Ecto.Query.dynamic(^left_expr != ^right_expr), acc} - - :> -> - {Ecto.Query.dynamic(^left_expr > ^right_expr), acc} - - :< -> - {Ecto.Query.dynamic(^left_expr < ^right_expr), acc} - - :>= -> - {Ecto.Query.dynamic(^left_expr >= ^right_expr), acc} - - :<= -> - {Ecto.Query.dynamic(^left_expr <= ^right_expr), acc} - - :+ -> - {Ecto.Query.dynamic(^left_expr + ^right_expr), acc} - - :- -> - {Ecto.Query.dynamic(^left_expr - ^right_expr), acc} - - :/ -> - {Ecto.Query.dynamic(^left_expr / ^right_expr), acc} - - :* -> - {Ecto.Query.dynamic(^left_expr * ^right_expr), acc} - - :<> -> - do_dynamic_expr( - query, - %Fragment{ - embedded?: pred_embedded?, - arguments: [ - raw: "(", - casted_expr: left_expr, - raw: " || ", - casted_expr: right_expr, - raw: ")" - ] - }, - bindings, - embedded?, - acc, - type - ) - - :|| -> - cond do - boolean_type?(left_type) and boolean_type?(right_type) and - cant_return_nil?(left) -> - {Ecto.Query.dynamic(^left_expr or ^right_expr), acc} - - boolean_type?(left_type) and boolean_type?(right_type) -> - {Ecto.Query.dynamic(coalesce(^left_expr or ^right_expr, false)), acc} - - cannot_be_boolean?(left_type) -> - {Ecto.Query.dynamic(coalesce(^left_expr, ^right_expr)), acc} - - true -> - if "ash-functions" in query.__ash_bindings__.sql_behaviour.repo( - query.__ash_bindings__.resource, - :mutate - ).installed_extensions() do - do_dynamic_expr( - query, - %Fragment{ - embedded?: pred_embedded?, - arguments: [ - raw: "ash_elixir_or(", - casted_expr: left_expr, - raw: ", ", - casted_expr: right_expr, - raw: ")" - ] - }, - bindings, - embedded?, - acc, - type - ) - else - if query.__ash_bindings__.sql_behaviour.require_ash_functions_for_or_and_and?() do - require_ash_functions!(query, "||") - end - - do_dynamic_expr( - query, - %Ash.Query.Function.Fragment{ - embedded?: pred_embedded?, - arguments: [ - raw: "(CASE WHEN (", - casted_expr: left_expr, - raw: " = FALSE OR ", - casted_expr: left_expr, - raw: " IS NULL) THEN ", - casted_expr: right_expr, - raw: " ELSE ", - casted_expr: left_expr, - raw: "END)" - ] - }, - bindings, - embedded?, - acc, - type - ) - end - end - - :&& -> - cond do - boolean_type?(left_type) and boolean_type?(right_type) and - cant_return_nil?(left) -> - {Ecto.Query.dynamic(^left_expr and ^right_expr), acc} - - boolean_type?(left_type) and boolean_type?(right_type) -> - {Ecto.Query.dynamic(coalesce(^left_expr and ^right_expr, false)), acc} - - true -> - if "ash-functions" in query.__ash_bindings__.sql_behaviour.repo( - query.__ash_bindings__.resource, - :mutate - ).installed_extensions() do - do_dynamic_expr( - query, - %Fragment{ - embedded?: pred_embedded?, - arguments: [ - raw: "ash_elixir_and(", - casted_expr: left_expr, - raw: ", ", - casted_expr: right_expr, - raw: ")" - ] - }, - bindings, - embedded?, - acc, - type - ) - else - if query.__ash_bindings__.sql_behaviour.require_ash_functions_for_or_and_and?() do - require_ash_functions!(query, "&&") - end - - do_dynamic_expr( - query, - %Fragment{ - embedded?: pred_embedded?, - arguments: [ - raw: "(CASE WHEN (", - casted_expr: left_expr, - raw: " = FALSE OR ", - casted_expr: left_expr, - raw: " IS NULL) THEN ", - casted_expr: left_expr, - raw: " ELSE ", - casted_expr: right_expr, - raw: "END)" - ] - }, - bindings, - embedded?, - acc, - type - ) - end - end - - other -> - raise "Operator not implemented #{other}" - end - end - end + typed_binary_operator_expr(query, op, bindings, embedded?, acc, type) end defp default_dynamic_expr(query, %MapSet{} = mapset, bindings, embedded?, acc, type) do @@ -2419,7 +2123,7 @@ defmodule AshSql.Expr do new_field_name = query.__ash_bindings__.aggregate_names[field_name] - unless new_field_name do + if !new_field_name do raise "Unbound aggregate field: #{inspect(field_name)}" end @@ -2761,7 +2465,7 @@ defmodule AshSql.Expr do acc = %{acc | has_error?: true} - unless Keyword.keyword?(input) || is_map(input) do + if !(Keyword.keyword?(input) || is_map(input)) do raise "Input expression to `error` must be a map or keyword list" end @@ -2900,216 +2604,220 @@ defmodule AshSql.Expr do query, %Exists{at_path: at_path, path: [first | rest], expr: expr} = exists, bindings, - _embedded?, + embedded?, acc, _type ) do - full_at_path = List.wrap(bindings[:refs_at_path]) ++ at_path - resource = Ash.Resource.Info.related(bindings.resource, full_at_path) - - first_relationship = Ash.Resource.Info.relationship(resource, first) + if Map.get(exists, :from_parent) do + parent_exists_expr(query, exists, bindings, embedded?, acc) + else + full_at_path = List.wrap(bindings[:refs_at_path]) ++ at_path + resource = Ash.Resource.Info.related(bindings.resource, full_at_path) - unless first_relationship do - raise Ash.Error.Framework.AssumptionFailed, - message: """ - Unknown relationship #{inspect(bindings.resource)}.#{first} + first_relationship = Ash.Resource.Info.relationship(resource, first) - in exists expression: `#{inspect(exists)}` - """ - end + if !first_relationship do + raise Ash.Error.Framework.AssumptionFailed, + message: """ + Unknown relationship #{inspect(bindings.resource)}.#{first} - filter = - case Ash.Filter.move_to_relationship_path(expr, rest) do - %Ash.Filter{expression: expression} -> expression - expression -> expression + in exists expression: `#{inspect(exists)}` + """ end - filter = - exists - |> Map.get(:__join_filters__, %{}) - |> Map.fetch([first_relationship.name]) - |> case do - {:ok, join_filter} -> - Ash.Query.BooleanExpression.optimized_new( - :and, - filter, - Ash.Filter.move_to_relationship_path( - join_filter, - rest ++ [first_relationship.name] - ) - ) - - :error -> - filter - end + filter = + case Ash.Filter.move_to_relationship_path(expr, rest) do + %Ash.Filter{expression: expression} -> expression + expression -> expression + end - filter = - exists - |> Map.get(:__join_filters__, %{}) - |> Map.delete([first_relationship.name]) - |> Enum.reduce(filter, fn {path, path_filter}, filter -> - path = Enum.drop(path, 1) - parent_path = :lists.droplast(path) - - Ash.Query.BooleanExpression.optimized_new( - :and, - filter, - Ash.Filter.move_to_relationship_path(path_filter, path) - ) - |> Ash.Filter.map(fn - %Ash.Query.Parent{expr: expr} -> - {:halt, Ash.Filter.move_to_relationship_path(expr, parent_path)} + filter = + exists + |> Map.get(:__join_filters__, %{}) + |> Map.fetch([first_relationship.name]) + |> case do + {:ok, join_filter} -> + Ash.Query.BooleanExpression.optimized_new( + :and, + filter, + Ash.Filter.move_to_relationship_path( + join_filter, + rest ++ [first_relationship.name] + ) + ) - other -> - other - end) - end) + :error -> + filter + end - # Joins for `rest` are derived from the refs in the filter and are left - # joins, so a predicate with no refs (e.g. `exists(a.bs, true)`) drops the - # remaining path entirely, and a null-satisfiable predicate is satisfied - # by null-extended rows. Requiring a non-nil primary key at every hop - # (not just the last: `no_attributes?` hops join with `on: true`) - # excludes both while being a no-op for real rows. - filter = - rest - |> Enum.scan([], fn rel_name, prefix -> prefix ++ [rel_name] end) - |> Enum.reduce(filter, fn prefix, filter -> - with target when not is_nil(target) <- - Ash.Resource.Info.related(first_relationship.destination, prefix), - [pk | _] <- Ash.Resource.Info.primary_key(target) do - pk_ref = %Ref{ - attribute: Ash.Resource.Info.attribute(target, pk), - relationship_path: prefix, - resource: target - } + filter = + exists + |> Map.get(:__join_filters__, %{}) + |> Map.delete([first_relationship.name]) + |> Enum.reduce(filter, fn {path, path_filter}, filter -> + path = Enum.drop(path, 1) + parent_path = :lists.droplast(path) Ash.Query.BooleanExpression.optimized_new( :and, filter, - %Ash.Query.Operator.IsNil{left: pk_ref, right: false} + Ash.Filter.move_to_relationship_path(path_filter, path) ) - else - _ -> filter - end - end) + |> Ash.Filter.map(fn + %Ash.Query.Parent{expr: expr} -> + {:halt, Ash.Filter.move_to_relationship_path(expr, parent_path)} - query = - if first_relationship.type == :many_to_many do - put_in(query.__ash_bindings__[:lateral_join_bindings], [:join_source]) - |> AshSql.Bindings.explicitly_set_binding( - %{ - type: :left, - path: [first_relationship.join_relationship] - }, - :join_source - ) - else - query - end - - {:ok, subquery} = - AshSql.Join.related_subquery(first_relationship, query, - filter: filter, - filter_subquery?: true, - sort?: Map.get(first_relationship, :from_many?) || not is_nil(first_relationship.sort), - start_bindings_at: 1, - select_star?: !Map.get(first_relationship, :manual), - in_group?: true, - refs_at_path: full_at_path, - parent_resources: [ - Ash.Resource.Info.related(resource, at_path) - | query.__ash_bindings__[:parent_resources] || [] - ], - return_subquery?: true, - on_subquery: fn subquery -> - subquery = - Ecto.Query.from(row in subquery, select: row) - |> Map.put(:__ash_bindings__, subquery.__ash_bindings__) - - cond do - Map.get(first_relationship, :manual) -> - {module, opts} = first_relationship.manual - - source_binding = - ref_binding( - %Ref{ - attribute: - Ash.Resource.Info.attribute(resource, first_relationship.source_attribute), - relationship_path: at_path, - resource: resource - }, - bindings - ) - - {:ok, subquery} = - apply( - module, - query.__ash_bindings__.sql_behaviour.manual_relationship_subquery_function(), - [ - opts, - source_binding, - 1, - subquery - ] - ) - - subquery - - Map.get(first_relationship, :no_attributes?) -> - subquery - - first_relationship.type == :many_to_many -> - source_ref = - ref_binding( - %Ref{ - attribute: - Ash.Resource.Info.attribute(resource, first_relationship.source_attribute), - relationship_path: at_path, - resource: resource - }, - bindings - ) - - through_relationship = - Ash.Resource.Info.relationship(resource, first_relationship.join_relationship) + other -> + other + end) + end) - {:ok, through} = - AshSql.Join.related_subquery(through_relationship, query) + # Joins for `rest` are derived from the refs in the filter and are left + # joins, so a predicate with no refs (e.g. `exists(a.bs, true)`) drops the + # remaining path entirely, and a null-satisfiable predicate is satisfied + # by null-extended rows. Requiring a non-nil primary key at every hop + # (not just the last: `no_attributes?` hops join with `on: true`) + # excludes both while being a no-op for real rows. + filter = + rest + |> Enum.scan([], fn rel_name, prefix -> prefix ++ [rel_name] end) + |> Enum.reduce(filter, fn prefix, filter -> + with target when not is_nil(target) <- + Ash.Resource.Info.related(first_relationship.destination, prefix), + [pk | _] <- Ash.Resource.Info.primary_key(target) do + pk_ref = %Ref{ + attribute: Ash.Resource.Info.attribute(target, pk), + relationship_path: prefix, + resource: target + } - Ecto.Query.from(destination in subquery, - join: through in ^through, - as: ^:join_source, - on: - field(through, ^first_relationship.destination_attribute_on_join_resource) == - field(destination, ^first_relationship.destination_attribute), - on: - field(parent_as(^source_ref), ^first_relationship.source_attribute) == - field(through, ^first_relationship.source_attribute_on_join_resource) - ) + Ash.Query.BooleanExpression.optimized_new( + :and, + filter, + %Ash.Query.Operator.IsNil{left: pk_ref, right: false} + ) + else + _ -> filter + end + end) - true -> - source_ref = - ref_binding( - %Ref{ - attribute: - Ash.Resource.Info.attribute(resource, first_relationship.source_attribute), - relationship_path: at_path, - resource: resource - }, - bindings - ) + query = + if first_relationship.type == :many_to_many do + put_in(query.__ash_bindings__[:lateral_join_bindings], [:join_source]) + |> AshSql.Bindings.explicitly_set_binding( + %{ + type: :left, + path: [first_relationship.join_relationship] + }, + :join_source + ) + else + query + end - Ecto.Query.from(destination in subquery, - where: - field(parent_as(^source_ref), ^first_relationship.source_attribute) == - field(destination, ^first_relationship.destination_attribute) - ) + {:ok, subquery} = + AshSql.Join.related_subquery(first_relationship, query, + filter: filter, + filter_subquery?: true, + sort?: Map.get(first_relationship, :from_many?) || not is_nil(first_relationship.sort), + start_bindings_at: 1, + select_star?: !Map.get(first_relationship, :manual), + in_group?: true, + refs_at_path: full_at_path, + parent_resources: [ + Ash.Resource.Info.related(resource, at_path) + | query.__ash_bindings__[:parent_resources] || [] + ], + return_subquery?: true, + on_subquery: fn subquery -> + subquery = + Ecto.Query.from(row in subquery, select: row) + |> Map.put(:__ash_bindings__, subquery.__ash_bindings__) + + cond do + Map.get(first_relationship, :manual) -> + {module, opts} = first_relationship.manual + + source_binding = + ref_binding( + %Ref{ + attribute: + Ash.Resource.Info.attribute(resource, first_relationship.source_attribute), + relationship_path: at_path, + resource: resource + }, + bindings + ) + + {:ok, subquery} = + apply( + module, + query.__ash_bindings__.sql_behaviour.manual_relationship_subquery_function(), + [ + opts, + source_binding, + 1, + subquery + ] + ) + + subquery + + Map.get(first_relationship, :no_attributes?) -> + subquery + + first_relationship.type == :many_to_many -> + source_ref = + ref_binding( + %Ref{ + attribute: + Ash.Resource.Info.attribute(resource, first_relationship.source_attribute), + relationship_path: at_path, + resource: resource + }, + bindings + ) + + through_relationship = + Ash.Resource.Info.relationship(resource, first_relationship.join_relationship) + + {:ok, through} = + AshSql.Join.related_subquery(through_relationship, query) + + Ecto.Query.from(destination in subquery, + join: through in ^through, + as: ^:join_source, + on: + field(through, ^first_relationship.destination_attribute_on_join_resource) == + field(destination, ^first_relationship.destination_attribute), + on: + field(parent_as(^source_ref), ^first_relationship.source_attribute) == + field(through, ^first_relationship.source_attribute_on_join_resource) + ) + + true -> + source_ref = + ref_binding( + %Ref{ + attribute: + Ash.Resource.Info.attribute(resource, first_relationship.source_attribute), + relationship_path: at_path, + resource: resource + }, + bindings + ) + + Ecto.Query.from(destination in subquery, + where: + field(parent_as(^source_ref), ^first_relationship.source_attribute) == + field(destination, ^first_relationship.destination_attribute) + ) + end end - end - ) + ) - {Ecto.Query.dynamic(exists(subquery)), acc} + {Ecto.Query.dynamic(exists(subquery)), acc} + end end defp default_dynamic_expr( @@ -3319,80 +3027,392 @@ defmodule AshSql.Expr do list_expr(query, value, bindings, embedded?, acc, {:array, type}) end - defp default_dynamic_expr(query, value, bindings, embedded?, acc, type) - when not is_nil(value) and is_atom(value) and not is_boolean(value) do - do_dynamic_expr(query, to_string(value), bindings, embedded?, acc, type) - end + defp default_dynamic_expr(query, value, bindings, embedded?, acc, type) + when not is_nil(value) and is_atom(value) and not is_boolean(value) do + do_dynamic_expr(query, to_string(value), bindings, embedded?, acc, type) + end + + defp default_dynamic_expr(_query, nil, bindings, _, acc, type) do + if type && type != :any do + param_type = parameterized_type(bindings.sql_behaviour, type, [], :expr) + + if param_type do + {Ecto.Query.dynamic(type(fragment("NULL"), ^param_type)), acc} + else + {Ecto.Query.dynamic(fragment("NULL")), acc} + end + else + {Ecto.Query.dynamic(fragment("NULL")), acc} + end + end + + defp default_dynamic_expr(query, value, bindings, false, acc, type) + when type == nil or type == :any do + if is_list(value) do + list_expr(query, value, bindings, false, acc, type) + else + handle_literal(query, value, bindings, true, acc, type) + end + end + + defp default_dynamic_expr(query, value, bindings, false, acc, type) do + if Ash.Expr.expr?(value) do + if is_list(value) do + list_expr(query, value, bindings, false, acc, type) + else + case bindings.sql_behaviour.expr(query, value, bindings, false, acc, type) do + {:ok, expr, acc} -> + {expr, acc} + + {:error, error} -> + raise "Error while building expression: #{error}" + + :error -> + raise "Unsupported expression in #{bindings.sql_behaviour} query: #{inspect(value, structs: false)}" + end + end + else + if bindings[:no_cast?] do + {value, acc} + else + case handle_literal(query, value, bindings, true, acc, type) do + {^value, acc} -> + if type do + type = + parameterized_type( + bindings.sql_behaviour, + type, + [], + :expr + ) + + if type do + validate_type!(query, type, value) + + {query.__ash_bindings__.sql_behaviour.type_expr(value, type), acc} + else + {value, acc} + end + else + {value, acc} + end + + {value, acc} -> + {value, acc} + end + end + end + end + + defp typed_binary_operator_expr( + query, + %mod{ + __predicate__?: _, + left: left, + right: right, + embedded?: pred_embedded?, + operator: operator + }, + bindings, + embedded?, + acc, + type + ) do + {[left_type, right_type], type} = + case operator do + :/ -> + {types, result} = determine_types(bindings.sql_behaviour, mod, [left, right], type) + + {types, result} = + {Enum.map(types, fn + {Ash.Type.Float, _} -> {Ash.Type.Decimal, []} + other -> other + end), + case result do + {Ash.Type.Float, _} -> {Ash.Type.Decimal, []} + other -> other + end} + + case result do + {Ash.Type.Decimal, _} -> + {Enum.map(types, fn _ -> {Ash.Type.Decimal, []} end), result} + + _ -> + {types, result} + end + + _ -> + determine_types(bindings.sql_behaviour, mod, [left, right], type) + end + + bindings = + if no_cast_for_native_value?(left, left_type) or + no_cast_for_native_value?(right, right_type) do + Map.put(bindings, :skip_cast_for_ref?, true) + else + bindings + end + + {left_expr, acc} = + if left_type do + maybe_type_expr( + query, + left, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc, + left_type + ) + else + do_dynamic_expr( + query, + left, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc, + left_type + ) + end + + with :in <- operator, + {:ok, item_type} <- extract_multidimensional_array_type(right_type), + {:ok, right} <- extract_list_value(right) do + Enum.reduce(right, {nil, acc}, fn item, {expr, acc} -> + {elem_expr, acc} = + do_dynamic_expr( + query, + item, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc, + item_type + ) + + if is_nil(expr) do + {Ecto.Query.dynamic(^left_expr == ^elem_expr), acc} + else + {Ecto.Query.dynamic(^expr or ^left_expr == ^elem_expr), acc} + end + end) + else + _ -> + if operator == :in do + get_path = strip_get_path_type(right) + + if match?(%Ash.Query.Function.GetPath{}, get_path) and get_path_array_type?(right_type) do + context_embedded? = pred_embedded? || embedded? + + {raw_right_expr, acc} = + get_untyped_get_path_expr( + query, + get_path, + bindings, + context_embedded?, + acc + ) + + {Ecto.Query.dynamic(fragment("(?::jsonb \\? ?)", ^raw_right_expr, ^left_expr)), acc} + else + {right_expr, acc} = + evaluate_right( + query, + right, + bindings, + pred_embedded? || embedded?, + acc, + right_type + ) + + {Ecto.Query.dynamic(^left_expr in ^right_expr), acc} + end + else + {right_expr, acc} = + evaluate_right( + query, + right, + bindings, + pred_embedded? || embedded?, + acc, + right_type + ) + + case operator do + :== -> + {Ecto.Query.dynamic(^left_expr == ^right_expr), acc} + + :!= -> + {Ecto.Query.dynamic(^left_expr != ^right_expr), acc} + + :> -> + {Ecto.Query.dynamic(^left_expr > ^right_expr), acc} + + :< -> + {Ecto.Query.dynamic(^left_expr < ^right_expr), acc} + + :>= -> + {Ecto.Query.dynamic(^left_expr >= ^right_expr), acc} + + :<= -> + {Ecto.Query.dynamic(^left_expr <= ^right_expr), acc} + + :+ -> + {Ecto.Query.dynamic(^left_expr + ^right_expr), acc} + + :- -> + {Ecto.Query.dynamic(^left_expr - ^right_expr), acc} + + :/ -> + {Ecto.Query.dynamic(^left_expr / ^right_expr), acc} + + :* -> + {Ecto.Query.dynamic(^left_expr * ^right_expr), acc} + + :<> -> + do_dynamic_expr( + query, + %Fragment{ + embedded?: pred_embedded?, + arguments: [ + raw: "(", + casted_expr: left_expr, + raw: " || ", + casted_expr: right_expr, + raw: ")" + ] + }, + bindings, + embedded?, + acc, + type + ) + + :|| -> + cond do + boolean_type?(left_type) and boolean_type?(right_type) and + cant_return_nil?(left) -> + {Ecto.Query.dynamic(^left_expr or ^right_expr), acc} - defp default_dynamic_expr(_query, nil, bindings, _, acc, type) do - if type && type != :any do - param_type = parameterized_type(bindings.sql_behaviour, type, [], :expr) + boolean_type?(left_type) and boolean_type?(right_type) -> + {Ecto.Query.dynamic(coalesce(^left_expr or ^right_expr, false)), acc} - if param_type do - {Ecto.Query.dynamic(type(fragment("NULL"), ^param_type)), acc} - else - {Ecto.Query.dynamic(fragment("NULL")), acc} - end - else - {Ecto.Query.dynamic(fragment("NULL")), acc} - end - end + cannot_be_boolean?(left_type) -> + {Ecto.Query.dynamic(coalesce(^left_expr, ^right_expr)), acc} - defp default_dynamic_expr(query, value, bindings, false, acc, type) - when type == nil or type == :any do - if is_list(value) do - list_expr(query, value, bindings, false, acc, type) - else - handle_literal(query, value, bindings, true, acc, type) - end - end + true -> + if "ash-functions" in query.__ash_bindings__.sql_behaviour.repo( + query.__ash_bindings__.resource, + :mutate + ).installed_extensions() do + do_dynamic_expr( + query, + %Fragment{ + embedded?: pred_embedded?, + arguments: [ + raw: "ash_elixir_or(", + casted_expr: left_expr, + raw: ", ", + casted_expr: right_expr, + raw: ")" + ] + }, + bindings, + embedded?, + acc, + type + ) + else + if query.__ash_bindings__.sql_behaviour.require_ash_functions_for_or_and_and?() do + require_ash_functions!(query, "||") + end - defp default_dynamic_expr(query, value, bindings, false, acc, type) do - if Ash.Expr.expr?(value) do - if is_list(value) do - list_expr(query, value, bindings, false, acc, type) - else - case bindings.sql_behaviour.expr(query, value, bindings, false, acc, type) do - {:ok, expr, acc} -> - {expr, acc} + do_dynamic_expr( + query, + %Ash.Query.Function.Fragment{ + embedded?: pred_embedded?, + arguments: [ + raw: "(CASE WHEN (", + casted_expr: left_expr, + raw: " = FALSE OR ", + casted_expr: left_expr, + raw: " IS NULL) THEN ", + casted_expr: right_expr, + raw: " ELSE ", + casted_expr: left_expr, + raw: "END)" + ] + }, + bindings, + embedded?, + acc, + type + ) + end + end - {:error, error} -> - raise "Error while building expression: #{error}" + :&& -> + cond do + boolean_type?(left_type) and boolean_type?(right_type) and + cant_return_nil?(left) -> + {Ecto.Query.dynamic(^left_expr and ^right_expr), acc} - :error -> - raise "Unsupported expression in #{bindings.sql_behaviour} query: #{inspect(value, structs: false)}" - end - end - else - if bindings[:no_cast?] do - {value, acc} - else - case handle_literal(query, value, bindings, true, acc, type) do - {^value, acc} -> - if type do - type = - parameterized_type( - bindings.sql_behaviour, - type, - [], - :expr - ) + boolean_type?(left_type) and boolean_type?(right_type) -> + {Ecto.Query.dynamic(coalesce(^left_expr and ^right_expr, false)), acc} - if type do - validate_type!(query, type, value) + true -> + if "ash-functions" in query.__ash_bindings__.sql_behaviour.repo( + query.__ash_bindings__.resource, + :mutate + ).installed_extensions() do + do_dynamic_expr( + query, + %Fragment{ + embedded?: pred_embedded?, + arguments: [ + raw: "ash_elixir_and(", + casted_expr: left_expr, + raw: ", ", + casted_expr: right_expr, + raw: ")" + ] + }, + bindings, + embedded?, + acc, + type + ) + else + if query.__ash_bindings__.sql_behaviour.require_ash_functions_for_or_and_and?() do + require_ash_functions!(query, "&&") + end - {query.__ash_bindings__.sql_behaviour.type_expr(value, type), acc} - else - {value, acc} + do_dynamic_expr( + query, + %Fragment{ + embedded?: pred_embedded?, + arguments: [ + raw: "(CASE WHEN (", + casted_expr: left_expr, + raw: " = FALSE OR ", + casted_expr: left_expr, + raw: " IS NULL) THEN ", + casted_expr: left_expr, + raw: " ELSE ", + casted_expr: right_expr, + raw: "END)" + ] + }, + bindings, + embedded?, + acc, + type + ) + end end - else - {value, acc} - end - {value, acc} -> - {value, acc} + other -> + raise "Operator not implemented #{other}" + end end - end end end @@ -4044,7 +4064,7 @@ defmodule AshSql.Expr do acc ) when is_atom(field) do - unless to_string(field) =~ ~r/^[a-zA-Z_][a-zA-Z0-9_]*$/ do + if !(to_string(field) =~ ~r/^[a-zA-Z_][a-zA-Z0-9_]*$/) do raise Ash.Error.Query.InvalidExpression, expression: field, message: "#{inspect(field)} is not a valid composite type field name" @@ -4090,7 +4110,7 @@ defmodule AshSql.Expr do installed_extensions = query.__ash_bindings__.sql_behaviour.repo(query.__ash_bindings__.resource, :mutate).installed_extensions() - unless "ash-functions" in installed_extensions do + if "ash-functions" not in installed_extensions do raise """ Cannot use `#{operator}` without adding the extension `ash-functions` to your repo. @@ -4401,4 +4421,116 @@ defmodule AshSql.Expr do defp get_path_array_type?({type, _}) when is_tuple(type), do: get_path_array_type?(type) defp get_path_array_type?(_), do: false + + # EXISTS against a parent to-many path. Inner parent() is the related row, not the parent resource. + defp parent_exists_expr( + query, + %Exists{path: [first | rest], expr: expr}, + bindings, + embedded?, + acc + ) do + parent_resource = bindings.parent_bindings.resource + relationship = Ash.Resource.Info.relationship(parent_resource, first) + + if !relationship do + raise Ash.Error.Framework.AssumptionFailed, + message: """ + Unknown relationship #{inspect(parent_resource)}.#{first} + + in parent exists expression + """ + end + + filter = + case Ash.Filter.move_to_relationship_path(expr, rest) do + %Ash.Filter{expression: expression} -> expression + expression -> expression + end + + parent_query = %{ + query + | __ash_bindings__: Map.put(bindings.parent_bindings, :parent?, true) + } + + source_attr = relationship.source_attribute + + parent_binding = + ref_binding( + %Ref{ + attribute: Ash.Resource.Info.attribute(parent_resource, source_attr), + relationship_path: [], + resource: parent_resource + }, + bindings.parent_bindings + ) || bindings.parent_bindings.root_binding + + {:ok, subquery} = + AshSql.Join.related_subquery(relationship, parent_query, + filter_subquery?: true, + start_bindings_at: 1, + select_star?: true, + in_group?: true, + return_subquery?: true, + on_subquery: fn subquery -> + subquery = + Ecto.Query.from(row in subquery, select: row) + |> Map.put(:__ash_bindings__, subquery.__ash_bindings__) + + inner_bindings = + set_location( + subquery.__ash_bindings__ + |> Map.put(:parent_bindings, Map.put(query.__ash_bindings__, :parent?, true)) + |> Map.put(:parent?, false), + :sub_expr + ) + + {pred, _acc} = + if filter in [nil, true] do + {true, acc} + else + do_dynamic_expr( + %{subquery | __ash_bindings__: inner_bindings}, + filter, + inner_bindings, + embedded?, + acc + ) + end + + cond do + Map.get(relationship, :no_attributes?) -> + Ecto.Query.from(destination in subquery, where: ^pred) + + relationship.type == :many_to_many -> + through_relationship = + Ash.Resource.Info.relationship(parent_resource, relationship.join_relationship) + + {:ok, through} = + AshSql.Join.related_subquery(through_relationship, parent_query) + + Ecto.Query.from(destination in subquery, + join: through in ^through, + on: + field(through, ^relationship.destination_attribute_on_join_resource) == + field(destination, ^relationship.destination_attribute), + on: + field(parent_as(^parent_binding), ^source_attr) == + field(through, ^relationship.source_attribute_on_join_resource), + where: ^pred + ) + + true -> + Ecto.Query.from(destination in subquery, + where: + field(parent_as(^parent_binding), ^source_attr) == + field(destination, ^relationship.destination_attribute), + where: ^pred + ) + end + end + ) + + {Ecto.Query.dynamic(exists(subquery)), acc} + end end diff --git a/lib/filter.ex b/lib/filter.ex index 6817f3e..0e7fb4e 100644 --- a/lib/filter.ex +++ b/lib/filter.ex @@ -8,6 +8,7 @@ defmodule AshSql.Filter do require Ecto.Query def filter(query, filter, resource, opts \\ []) do + filter = rewrite_cartesian_parent_filter(query, filter) used_aggregates = Ash.Filter.used_aggregates(filter, []) query @@ -35,7 +36,8 @@ defmodule AshSql.Filter do end def add_filter_expression(query, filter) do - filter + query + |> rewrite_cartesian_parent_filter(filter) |> AshSql.Expr.split_statements(:and) |> Enum.reduce(query, fn filter, query -> {dynamic, acc} = @@ -57,4 +59,11 @@ defmodule AshSql.Filter do |> AshSql.Expr.merge_accumulator(acc) end) end + + defp rewrite_cartesian_parent_filter(query, filter) do + AshSql.Join.rewrite_filter_if_cartesian_parent( + filter, + AshSql.Join.cartesian_parent_resource(query) + ) + end end diff --git a/lib/join.ex b/lib/join.ex index d8a1893..ad90df0 100644 --- a/lib/join.ex +++ b/lib/join.ex @@ -257,7 +257,21 @@ defmodule AshSql.Join do {:ok, query} lateral_join_source_query -> - case join_all_relationships(lateral_join_source_query, parent_expr(filter)) do + # Joining two parent to-many paths cartesian-products rows. Use EXISTS instead. + as_exists? = + cartesian_parent_join?( + filter, + lateral_join_source_query.__ash_bindings__.resource + ) + + parent_filter = + if as_exists? do + true + else + parent_expr(filter) + end + + case join_all_relationships(lateral_join_source_query, parent_filter) do {:ok, lateral_join_source_query} -> {:ok, put_in(query.__ash_bindings__.lateral_join_source_query, lateral_join_source_query) @@ -279,6 +293,129 @@ defmodule AshSql.Join do {:ok, query} end + # True when parent() names two or more to-many relationships (those would be joined). + defp cartesian_parent_join?(filter, parent_resource) do + filter + |> parent_expr() + |> Ash.Filter.relationship_paths() + |> Enum.reject(&(&1 == [])) + |> Enum.uniq() + |> Enum.filter(fn + [first | _] -> + case Ash.Resource.Info.relationship(parent_resource, first) do + %{cardinality: :many} -> true + _ -> false + end + + _ -> + false + end) + |> length() + |> Kernel.>=(2) + end + + defp maybe_rewrite_cartesian_parent_query(%{valid?: false} = query, _parent_resource), + do: query + + defp maybe_rewrite_cartesian_parent_query(query, parent_resource) do + %{query | filter: rewrite_filter_if_cartesian_parent(query.filter, parent_resource)} + end + + @doc false + def rewrite_filter_if_cartesian_parent(filter, parent_resource) + when is_nil(filter) or is_nil(parent_resource) or filter in [true, false] do + filter + end + + def rewrite_filter_if_cartesian_parent(filter, parent_resource) do + if cartesian_parent_join?(filter, parent_resource) do + rewrite_parent_paths_as_exists(filter, parent_resource) + else + filter + end + end + + @doc false + def cartesian_parent_resource(query) do + case query.__ash_bindings__[:lateral_join_source_query] do + %{__ash_bindings__: %{resource: resource}} -> + resource + + _ -> + case query.__ash_bindings__[:parent_bindings] do + %{resource: resource} -> resource + _ -> nil + end + end + end + + # Keep and/or/not. Turn each parent(to_many.*) leaf into exists from the parent. + defp rewrite_parent_paths_as_exists(filter, parent_resource) do + Ash.Filter.map(filter, fn + %Ash.Query.BooleanExpression{} = expr -> + expr + + %Ash.Query.Not{} = expr -> + expr + + %Ash.Query.Exists{} = expr -> + {:halt, expr} + + expr -> + case parent_to_many_first_hops(expr, parent_resource) do + [first] -> + {:halt, exists_from_parent_path(expr, first)} + + _ -> + expr + end + end) + end + + defp parent_to_many_first_hops(expr, parent_resource) do + expr + |> Ash.Filter.flat_map(fn + %Ash.Query.Parent{expr: %Ref{relationship_path: [first | _]}} -> + case Ash.Resource.Info.relationship(parent_resource, first) do + %{cardinality: :many} -> [first] + _ -> [] + end + + %Ash.Query.Parent{expr: inner} -> + parent_to_many_first_hops(inner, parent_resource) + + _ -> + [] + end) + |> Enum.uniq() + end + + defp exists_from_parent_path(expr, first) do + rewritten = + Ash.Filter.map(expr, fn + %Ash.Query.Parent{expr: %Ref{relationship_path: [^first | rest]} = ref} -> + {:halt, %{ref | relationship_path: rest}} + + %Ref{relationship_path: []} = ref -> + {:halt, %Ash.Query.Parent{expr: ref}} + + other -> + other + end) + + inner = + case rewritten do + %Ash.Filter{expression: expression} -> expression + expression -> expression + end + + Map.put( + %Ash.Query.Exists{path: [first], expr: inner, at_path: [], related?: true}, + :from_parent, + true + ) + end + defp to_joins(paths, filter, resource) do paths |> Enum.reject(&(&1 == [])) @@ -501,6 +638,7 @@ defmodule AshSql.Join do end end) |> set_has_parent_expr_context(relationship) + |> maybe_rewrite_cartesian_parent_query(relationship.source) |> case do %{valid?: true} = related_query -> parent_bindings =