diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c06f47..c5b4c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,77 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline +## [v0.7.0](https://github.com/ash-project/ash_sql/compare/v0.6.9...v0.7.0) (2026-08-20) + + + + +### Features: + +* render the range expressions to their Postgres operators by [@zachdaniel](https://github.com/zachdaniel) [(#248)](https://github.com/ash-project/ash_sql/pull/248) + +* render the range expressions to their Postgres operators by [@matt-beanland](https://github.com/matt-beanland) [(#248)](https://github.com/ash-project/ash_sql/pull/248) + +### Bug Fixes: + +* pass actor to Ash.Query.for_read for aggregate and sort by [@zachdaniel](https://github.com/zachdaniel) [(#250)](https://github.com/ash-project/ash_sql/pull/250) + +* respect opts[:left_only?] argument passed to join_all_relationships by [@zachdaniel](https://github.com/zachdaniel) [(#249)](https://github.com/ash-project/ash_sql/pull/249) + +* pass actor to Ash.Query.for_read for aggregate and sort by Jonas Cosandey [(#250)](https://github.com/ash-project/ash_sql/pull/250) + +* respect opts[:left_only?] argument passed to join_all_relationships by Jonas Cosandey [(#249)](https://github.com/ash-project/ash_sql/pull/249) + +## [v0.6.9](https://github.com/ash-project/ash_sql/compare/v0.6.8...v0.6.9) (2026-08-16) + + + + +### Improvements: + +* add Implementation.list_expr/6 so adapters can render list literals by [@zachdaniel](https://github.com/zachdaniel) [(#247)](https://github.com/ash-project/ash_sql/pull/247) + +## [v0.6.8](https://github.com/ash-project/ash_sql/compare/v0.6.7...v0.6.8) (2026-08-14) + + + + +### Improvements: + +* support a cast mechanism when casting refs by [@zachdaniel](https://github.com/zachdaniel) + +* support the Duration form of ago, from_now, datetime_add… by [@zachdaniel](https://github.com/zachdaniel) [(#245)](https://github.com/ash-project/ash_sql/pull/245) + +* support the Duration form of ago, from_now, datetime_add and date_add by [@matt-beanland](https://github.com/matt-beanland) [(#245)](https://github.com/ash-project/ash_sql/pull/245) + +## [v0.6.7](https://github.com/ash-project/ash_sql/compare/v0.6.6...v0.6.7) (2026-08-11) + + + + +### Bug Fixes: + +* fix aggregate references inside of unions by [@zachdaniel](https://github.com/zachdaniel) + +* apply each combination part to the whole of what precedes it by [@matt-beanland](https://github.com/matt-beanland) [(#244)](https://github.com/ash-project/ash_sql/pull/244) + +## [v0.6.6](https://github.com/ash-project/ash_sql/compare/v0.6.5...v0.6.6) (2026-07-22) + + + + +### Bug Fixes: + +* ensure limits/offsets are applied in all aggregates by [@zachdaniel](https://github.com/zachdaniel) + +* use aggregate read_action instead of using default relationship read (#239) by averypeck [(#239)](https://github.com/ash-project/ash_sql/pull/239) + +* preserve shared context in lateral join source queries (#238) by [@nallwhy](https://github.com/nallwhy) [(#238)](https://github.com/ash-project/ash_sql/pull/238) + +### Improvements: + +* Use relationship read action arguments in joins (#237) by [@jechol](https://github.com/jechol) [(#237)](https://github.com/ash-project/ash_sql/pull/237) + ## [v0.6.5](https://github.com/ash-project/ash_sql/compare/v0.6.4...v0.6.5) (2026-06-20) diff --git a/README.md b/README.md index f1e1ac0..195167f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Shared functionality for ecto-based sql data layers. ```elixir def deps do [ - {:ash_sql, "~> 0.6.5"} + {:ash_sql, "~> 0.7.0"} ] end ``` diff --git a/lib/aggregate.ex b/lib/aggregate.ex index 50243ee..9acaff5 100644 --- a/lib/aggregate.ex +++ b/lib/aggregate.ex @@ -188,7 +188,11 @@ defmodule AshSql.Aggregate do raise "No such relationship #{inspect(resource)}.#{first_relationship}. aggregates: #{inspect(aggregates)}" first_relationship -> - {first_relationship, rest} + if rest == [] do + {override_read_action(first_relationship, read_action.name), rest} + else + {first_relationship, rest} + end end end @@ -513,31 +517,40 @@ defmodule AshSql.Aggregate do tenant, join_filters ) do + limited? = limited_relationship?(first_relationship) + AshSql.Join.related_subquery( first_relationship, tmp_query, start_bindings_at: start_bindings_at, refs_at_path: root_data_path, skip_distinct_for_first_rel?: true, + sort?: limited?, on_subquery: fn subquery -> base_binding = subquery.__ash_bindings__.root_binding current_binding = subquery.__ash_bindings__.current subquery = - subquery - |> Ecto.Query.exclude(:select) - |> Ecto.Query.select(%{}) - - subquery = - apply_relationship_subquery( - subquery, - first_relationship, - query, - tenant, - source_binding, - current_binding, - base_binding - ) + if limited? do + apply_limited_relationship_subquery( + subquery, + first_relationship, + source_binding, + base_binding + ) + else + subquery + |> Ecto.Query.exclude(:select) + |> Ecto.Query.select(%{}) + |> apply_relationship_subquery( + first_relationship, + query, + tenant, + source_binding, + current_binding, + base_binding + ) + end subquery = AshSql.Join.set_join_prefix( @@ -598,6 +611,63 @@ defmodule AshSql.Aggregate do ) end + # Relationships that declare a `limit` (or `offset`) need the limit applied + # to the correlated rows *before* the aggregation's `GROUP BY`, otherwise the + # limit caps the number of groups (always 1 in a lateral join) instead of the + # number of rows per group. + defp limited_relationship?(relationship) do + (is_integer(Map.get(relationship, :limit)) or + (Map.get(relationship, :offset) || 0) > 0) and + is_nil(Map.get(relationship, :manual)) and + !Map.get(relationship, :no_attributes?) and + relationship.type != :many_to_many + end + + # Builds: + # + # SELECT ... FROM ( + # SELECT * FROM destination + # WHERE destination.destination_attribute = parent.source_attribute + # ORDER BY LIMIT + # ) AS + # GROUP BY destination_attribute + # + # The correlation, sort and limit all live in the inner subquery so that the + # limit bounds the rows per parent, and the aggregate functions fold the + # already-limited rows. + defp apply_limited_relationship_subquery(subquery, rel, source_binding, base_binding) do + field = rel.destination_attribute + + inner = + from(row in subquery, + where: + field( + parent_as(^source_binding), + ^rel.source_attribute + ) == + field( + as(^base_binding), + ^rel.destination_attribute + ) + ) + + inner = + case Map.get(rel, :limit) do + limit when is_integer(limit) -> Ecto.Query.limit(inner, ^limit) + _ -> inner + end + + from(row in subquery(inner), as: ^base_binding) + |> Map.put(:__ash_bindings__, subquery.__ash_bindings__) + |> Ecto.Query.select(%{}) + |> then(fn wrapped -> + from(row in wrapped, + group_by: field(row, ^field), + select_merge: %{^field => field(row, ^field)} + ) + end) + end + defp apply_relationship_subquery( subquery, %{manual: {module, opts}} = rel, @@ -899,41 +969,11 @@ defmodule AshSql.Aggregate do {:cont, {:ok, [aggregate | aggregates]}} aggregate, {:ok, aggregates} -> - related = Ash.Resource.Info.related(resource, aggregate.relationship_path) - - read_action = - aggregate.read_action || Ash.Resource.Info.primary_action!(related, :read).name - - with %{valid?: true} = aggregate_query <- Ash.Query.for_read(related, read_action), - %{valid?: true} = aggregate_query <- - Ash.Query.build(aggregate_query, filter: aggregate.filter, sort: aggregate.sort) do - Ash.Query.Aggregate.new( - resource, - aggregate.name, - aggregate.kind, - path: aggregate.relationship_path, - query: aggregate_query, - field: aggregate.field, - default: aggregate.default, - filterable?: aggregate.filterable?, - type: aggregate.type, - sortable?: aggregate.filterable?, - include_nil?: aggregate.include_nil?, - constraints: aggregate.constraints, - implementation: aggregate.implementation, - uniq?: aggregate.uniq?, - read_action: - aggregate.read_action || - Ash.Resource.Info.primary_action!( - Ash.Resource.Info.related(resource, aggregate.relationship_path), - :read - ).name, - authorize?: aggregate.authorize? - ) - else - %{errors: errors} -> - {:error, errors} - end + resource + |> resource_aggregate_to_aggregate(aggregate, + actor: private_context[:actor], + tenant: private_context[:tenant] + ) |> case do {:ok, aggregate} -> aggregate = @@ -957,6 +997,44 @@ defmodule AshSql.Aggregate do end) end + @doc false + def resource_aggregate_to_aggregate(resource, aggregate, opts \\ []) do + related = Ash.Resource.Info.related(resource, aggregate.relationship_path) + + read_action = + aggregate.read_action || Ash.Resource.Info.primary_action!(related, :read).name + + with %{valid?: true} = aggregate_query <- + Ash.Query.for_read(related, read_action, %{}, + actor: opts[:actor], + tenant: opts[:tenant] + ), + %{valid?: true} = aggregate_query <- + Ash.Query.build(aggregate_query, filter: aggregate.filter, sort: aggregate.sort) do + Ash.Query.Aggregate.new( + resource, + aggregate.name, + aggregate.kind, + path: aggregate.relationship_path, + query: aggregate_query, + field: aggregate.field, + default: aggregate.default, + filterable?: aggregate.filterable?, + type: aggregate.type, + sortable?: aggregate.filterable?, + include_nil?: aggregate.include_nil?, + constraints: aggregate.constraints, + implementation: aggregate.implementation, + uniq?: aggregate.uniq?, + read_action: read_action, + authorize?: aggregate.authorize? + ) + else + %{errors: errors} -> + {:error, errors} + end + end + defp add_first_join_aggregate( query, _resource, @@ -1561,7 +1639,7 @@ defmodule AshSql.Aggregate do defp join_all_relationships( agg_root_query, - _aggregates, + aggregates, relationship_path, first_relationship, _is_single?, @@ -1579,16 +1657,17 @@ defmodule AshSql.Aggregate do end end) + relationships = + first_relationship.destination + |> AshSql.Join.relationship_path_to_relationships(relationship_path) + |> List.update_at(-1, &override_read_action(&1, hd(aggregates).query.action.name)) + AshSql.Join.join_all_relationships( agg_root_query, Map.values(join_filters), [], [ - {:inner, - AshSql.Join.relationship_path_to_relationships( - first_relationship.destination, - relationship_path - )} + {:inner, relationships} ], [], nil, @@ -1707,7 +1786,7 @@ defmodule AshSql.Aggregate do relationship_path: relationship_path, join_filters: join_filters, field: %Ash.Query.Calculation{} = field - }, + } = aggregate, _ ) do ref = @@ -1719,7 +1798,8 @@ defmodule AshSql.Aggregate do with true <- join_filters == %{}, [] <- Ash.Filter.used_aggregates(ref, :all), - [] <- Ash.Filter.relationship_paths(ref) do + [] <- Ash.Filter.relationship_paths(ref), + true <- read_action_matches_relationship_default?(resource, aggregate) do true else _ -> @@ -1767,7 +1847,8 @@ defmodule AshSql.Aggregate do } with [] <- Ash.Filter.used_aggregates(ref, :all), - [] <- Ash.Filter.relationship_paths(ref) do + [] <- Ash.Filter.relationship_paths(ref), + true <- read_action_matches_relationship_default?(resource, aggregate) do true else _ -> @@ -1778,9 +1859,10 @@ defmodule AshSql.Aggregate do false _ -> - name in query.__ash_bindings__.sql_behaviour.simple_join_first_aggregates(resource) || - (join_filters in [nil, %{}, []] && - single_path?(resource, relationship_path)) + (name in query.__ash_bindings__.sql_behaviour.simple_join_first_aggregates(resource) || + (join_filters in [nil, %{}, []] && + single_path?(resource, relationship_path))) && + read_action_matches_relationship_default?(resource, aggregate) end end @@ -2489,6 +2571,34 @@ defmodule AshSql.Aggregate do end end + defp read_action_matches_relationship_default?(_resource, %{relationship_path: []}), do: true + + defp read_action_matches_relationship_default?(resource, aggregate) do + last_relationship = + resource + |> AshSql.Join.relationship_path_to_relationships(aggregate.relationship_path) + |> List.last() + + default_action_name = + last_relationship.read_action || + Ash.Resource.Info.primary_action!(last_relationship.destination, :read).name + + case aggregate.query && aggregate.query.action do + nil -> true + action -> action.name == default_action_name + end + end + + defp override_read_action(relationship, action_name) do + if relationship.read_action == action_name do + relationship + else + relationship + |> Map.put(:read_action, action_name) + |> Map.replace(:read_action_arguments, %{}) + end + end + defp single_path?(_, []), do: true defp single_path?(resource, [relationship | rest]) do @@ -2577,8 +2687,20 @@ defmodule AshSql.Aggregate do |> Ash.Resource.Info.attribute_names() |> MapSet.to_list() + # An upgraded combination query's source only exposes the default + # attributes plus the combination fieldset; others can't be re-selected. + available_attr_names = + if query.__ash_bindings__[:subquery_upgrade?] do + MapSet.union( + selected_by_default, + MapSet.new(query.__ash_bindings__[:already_selected] || []) + ) + else + MapSet.new(all_attr_names) + end + to_select = - Enum.reject(all_attr_names, &(&1 in selected_fields)) + Enum.reject(all_attr_names, &(&1 in selected_fields or &1 not in available_attr_names)) query_with_all_attrs = case query.select do @@ -2622,21 +2744,36 @@ defmodule AshSql.Aggregate do select_aggregates = (query_with_all_attrs.__ash_bindings__[:select_aggregates] || []) -- [:aggregates] + reselected_fields = + Enum.concat([ + selected_fields, + select_calculations, + select_aggregates, + flattened_calc_fields, + flattened_agg_fields + ]) + + # Upgraded combination queries return maps, which `struct/2` rejects, and + # `map/2` would drop field type info — merge each field explicitly instead. subquery_query = - from(row in subquery(query_with_all_attrs), - as: ^query.__ash_bindings__.root_binding, - select: - struct( - row, - ^Enum.concat([ - selected_fields, - select_calculations, - select_aggregates, - flattened_calc_fields, - flattened_agg_fields - ]) + if query.__ash_bindings__[:subquery_upgrade?] do + root_binding = query.__ash_bindings__.root_binding + + base = + from(row in subquery(query_with_all_attrs), + as: ^root_binding, + select: %{} ) - ) + + Enum.reduce(reselected_fields, base, fn field, q -> + from(row in q, select_merge: %{^field => field(as(^root_binding), ^field)}) + end) + else + from(row in subquery(query_with_all_attrs), + as: ^query.__ash_bindings__.root_binding, + select: struct(row, ^reselected_fields) + ) + end root_binding = query.__ash_bindings__.root_binding diff --git a/lib/expr.ex b/lib/expr.ex index 0ba6f4b..302d277 100644 --- a/lib/expr.ex +++ b/lib/expr.ex @@ -32,7 +32,11 @@ defmodule AshSql.Expr do Lazy, Length, Now, + RangeAdjacent, + RangeContains, + RangeLower, RangeOverlaps, + RangeUpper, Rem, Round, StartOfDay, @@ -310,6 +314,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?}, @@ -451,6 +476,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?}, @@ -483,6 +529,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?}, @@ -514,6 +590,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{ @@ -1068,6 +1176,114 @@ defmodule AshSql.Expr do end end + defp default_dynamic_expr( + query, + %RangeOverlaps{arguments: [left, right], embedded?: pred_embedded?}, + bindings, + embedded?, + acc, + _type + ) do + {[left_type, right_type], _type} = + determine_types(bindings.sql_behaviour, RangeOverlaps, [left, right], :boolean) + + {left_expr, acc} = + range_operand(query, left, left_type, bindings, pred_embedded? || embedded?, acc) + + {right_expr, acc} = + range_operand(query, right, right_type, bindings, pred_embedded? || embedded?, acc) + + {Ecto.Query.dynamic(fragment("(? && ?)", ^left_expr, ^right_expr)), acc} + end + + defp default_dynamic_expr( + query, + %RangeContains{arguments: [left, right], embedded?: pred_embedded?}, + bindings, + embedded?, + acc, + _type + ) do + {[left_type, right_type], _type} = + determine_types(bindings.sql_behaviour, RangeContains, [left, right], :boolean) + + {left_expr, acc} = + range_operand(query, left, left_type, bindings, pred_embedded? || embedded?, acc) + + {right_expr, acc} = + range_operand(query, right, right_type, bindings, pred_embedded? || embedded?, acc) + + {Ecto.Query.dynamic(fragment("(? @> ?)", ^left_expr, ^right_expr)), acc} + end + + defp default_dynamic_expr( + query, + %RangeAdjacent{arguments: [left, right], embedded?: pred_embedded?}, + bindings, + embedded?, + acc, + _type + ) do + {[left_type, right_type], _type} = + determine_types(bindings.sql_behaviour, RangeAdjacent, [left, right], :boolean) + + {left_expr, acc} = + range_operand(query, left, left_type, bindings, pred_embedded? || embedded?, acc) + + {right_expr, acc} = + range_operand(query, right, right_type, bindings, pred_embedded? || embedded?, acc) + + {Ecto.Query.dynamic(fragment("(? -|- ?)", ^left_expr, ^right_expr)), acc} + end + + defp default_dynamic_expr( + query, + %RangeLower{arguments: [range], embedded?: pred_embedded?}, + bindings, + embedded?, + acc, + type + ) do + {[determined_type], type} = + determine_types(bindings.sql_behaviour, RangeLower, [range], type) + + {range_expr, acc} = + do_dynamic_expr( + query, + range, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc, + determined_type || type + ) + + {Ecto.Query.dynamic(fragment("lower(?)", ^range_expr)), acc} + end + + defp default_dynamic_expr( + query, + %RangeUpper{arguments: [range], embedded?: pred_embedded?}, + bindings, + embedded?, + acc, + type + ) do + {[determined_type], type} = + determine_types(bindings.sql_behaviour, RangeUpper, [range], type) + + {range_expr, acc} = + do_dynamic_expr( + query, + range, + set_location(bindings, :sub_expr), + pred_embedded? || embedded?, + acc, + determined_type || type + ) + + {Ecto.Query.dynamic(fragment("upper(?)", ^range_expr)), acc} + end + defp default_dynamic_expr( query, %Length{arguments: [list], embedded?: pred_embedded?}, @@ -1081,9 +1297,11 @@ defmodule AshSql.Expr do %Fragment{ embedded?: pred_embedded?, arguments: [ - raw: "array_length((", + raw: "coalesce(array_length((", expr: list, - raw: "), 1)" + raw: "), 1), cardinality((", + expr: list, + raw: ")))" ] }, bindings, @@ -1250,11 +1468,11 @@ defmodule AshSql.Expr do %Fragment{ embedded?: pred_embedded?, arguments: [ - raw: "#{bindings.sql_behaviour.strpos_function()}((", + raw: "(NULLIF(#{bindings.sql_behaviour.strpos_function()}((", expr: left, raw: "), (", expr: right, - raw: "))" + raw: ")), 0) - 1)" ] }, bindings, @@ -2243,15 +2461,15 @@ defmodule AshSql.Expr do acc, _type ) do - precision = Enum.at(rest, 0) || 1 + precision = Enum.at(rest, 0) || 0 frag = %Fragment{ embedded?: pred_embedded?, arguments: [ - raw: "ROUND(", + raw: "ROUND((", expr: num, - raw: ", ", + raw: ")::numeric, ", expr: precision, raw: ")" ] @@ -2327,16 +2545,29 @@ defmodule AshSql.Expr do ) if type do - bindings = + {bindings, cast_type} = case arg1 do - %Ash.Query.Ref{attribute: %Ash.Resource.Attribute{}} -> - Map.put(bindings, :no_cast?, true) + %Ash.Query.Ref{ + attribute: %Ash.Resource.Attribute{type: attr_type, constraints: attr_constraints} + } -> + # only remap the cast for refs being cast to their own type — + # cross-type casts (e.g. a usec column cast to second precision) + # must keep their requested target + cast_type = + if type == + parameterized_type(bindings.sql_behaviour, attr_type, attr_constraints, :expr) do + bindings.sql_behaviour.ref_cast_type(type) + else + type + end + + {Map.put(bindings, :no_cast?, true), cast_type} _ -> if Ash.Expr.expr?(arg1) do - bindings + {bindings, type} else - Map.put(bindings, :no_cast?, true) + {Map.put(bindings, :no_cast?, true), type} end end @@ -2367,7 +2598,7 @@ defmodule AshSql.Expr do {expr, acc} _ -> - {query.__ash_bindings__.sql_behaviour.type_expr(expr, type), acc} + {query.__ash_bindings__.sql_behaviour.type_expr(expr, cast_type), acc} end else do_dynamic_expr(query, arg1, bindings, embedded?, acc, arg2) @@ -2422,34 +2653,6 @@ defmodule AshSql.Expr do {query.__ash_bindings__.sql_behaviour.type_expr(frag, type), acc} end - defp default_dynamic_expr( - query, - %RangeOverlaps{arguments: [left, right], embedded?: pred_embedded?}, - bindings, - embedded?, - acc, - _type - ) do - # Infer each operand's range type (so a literal range picks up the column's - # type, including its inner_type, and dumps to a native range). - {[left_type, right_type], _} = - determine_types(bindings.sql_behaviour, RangeOverlaps, [left, right], :boolean) - - {left_expr, acc} = - type_or_dynamic_expr(query, left, set_location(bindings, :sub_expr), pred_embedded? || embedded?, acc, left_type) - - {right_expr, acc} = - type_or_dynamic_expr(query, right, set_location(bindings, :sub_expr), pred_embedded? || embedded?, acc, right_type) - - {Ecto.Query.dynamic(fragment("(? && ?)", ^left_expr, ^right_expr)), acc} - end - - defp type_or_dynamic_expr(query, expr, bindings, embedded?, acc, nil), - do: do_dynamic_expr(query, expr, bindings, embedded?, acc, nil) - - defp type_or_dynamic_expr(query, expr, bindings, embedded?, acc, type), - do: maybe_type_expr(query, expr, bindings, embedded?, acc, type) - defp default_dynamic_expr( query, %Now{embedded?: pred_embedded?}, @@ -2921,7 +3124,10 @@ defmodule AshSql.Expr do Ecto.Query.dynamic(field(as(^ref_binding), ^name)) end - query.__ash_bindings__.sql_behaviour.type_expr(ref_dynamic, type) + query.__ash_bindings__.sql_behaviour.type_expr( + ref_dynamic, + query.__ash_bindings__.sql_behaviour.ref_cast_type(type) + ) end {expr, acc} @@ -3120,6 +3326,14 @@ defmodule AshSql.Expr do end end + defp range_operand(query, operand, nil, bindings, embedded?, acc) do + do_dynamic_expr(query, operand, set_location(bindings, :sub_expr), embedded?, acc, nil) + end + + defp range_operand(query, operand, type, bindings, embedded?, acc) do + maybe_type_expr(query, operand, set_location(bindings, :sub_expr), embedded?, acc, type) + end + defp extract_list_value(value) when is_list(value), do: {:ok, value} defp extract_list_value(%MapSet{} = value), do: {:ok, value} @@ -3317,6 +3531,8 @@ defmodule AshSql.Expr do :bracket end + declared_field = type && field_constraints(constraints, next) + {next, type, constraints} = cond do type && Ash.Type.embedded_type?(type) -> @@ -3353,6 +3569,10 @@ defmodule AshSql.Expr do {name, type, constraints} end + declared_field -> + {field_type, field_constraints} = declared_field + {next, field_type, field_constraints} + true -> {next, nil, nil} end @@ -3396,6 +3616,20 @@ defmodule AshSql.Expr do end end + # The type a `fields` constraint declares for `next`, if it declares one. + defp field_constraints(constraints, next) do + with true <- Keyword.keyword?(constraints || []), + fields when is_list(fields) <- constraints[:fields], + {_name, config} <- + Enum.find(fields, fn {name, _config} -> + if is_binary(next), do: to_string(name) == next, else: name == next + end) do + {config[:type], config[:constraints] || []} + else + _ -> nil + end + end + defp list_requires_encoding?(value) do !Enum.empty?(value) && Enum.any?(value, fn value -> @@ -3425,57 +3659,72 @@ defmodule AshSql.Expr do value end - element_type = - case type do - {{:array, type}, _} -> type - {{:in, type}, _} -> type - _ -> nil - end + case bindings.sql_behaviour.list_expr(query, value, bindings, embedded?, acc, type) do + {:ok, expr, acc} -> + {expr, acc} - elements = - Enum.map(value, fn list_item -> - if type do - {:expr, %Ash.Query.Function.Type{arguments: [list_item, element_type, []]}} - else - {:expr, list_item} - end - end) - |> Enum.intersperse({:raw, ","}) + {:error, error} -> + raise "Error while building list expression: #{error}" - if is_map? do - do_dynamic_expr( - query, - %Fragment{ - embedded?: embedded?, - arguments: - [ - raw: "array_to_json(ARRAY[" - ] ++ elements ++ [raw: "])"] - }, - bindings, - embedded?, - acc, - type - ) - else - do_dynamic_expr( - query, - %Fragment{ - embedded?: embedded?, - arguments: - [ - raw: "ARRAY[" - ] ++ elements ++ [raw: "]"] - }, - bindings, - embedded?, - acc, - type - ) + :error -> + default_encode_list(query, value, bindings, embedded?, acc, type, is_map?) end end end + # `ARRAY[...]` is Postgres syntax. It stays the default so implementations that do not + # override `list_expr/6` render exactly as they did before. + defp default_encode_list(query, value, bindings, embedded?, acc, type, is_map?) do + element_type = + case type do + {{:array, type}, _} -> type + {{:in, type}, _} -> type + _ -> nil + end + + elements = + Enum.map(value, fn list_item -> + if type do + {:expr, %Ash.Query.Function.Type{arguments: [list_item, element_type, []]}} + else + {:expr, list_item} + end + end) + |> Enum.intersperse({:raw, ","}) + + if is_map? do + do_dynamic_expr( + query, + %Fragment{ + embedded?: embedded?, + arguments: + [ + raw: "array_to_json(ARRAY[" + ] ++ elements ++ [raw: "])"] + }, + bindings, + embedded?, + acc, + type + ) + else + do_dynamic_expr( + query, + %Fragment{ + embedded?: embedded?, + arguments: + [ + raw: "ARRAY[" + ] ++ elements ++ [raw: "]"] + }, + bindings, + embedded?, + acc, + type + ) + end + end + defp list_expr(query, value, bindings, embedded?, acc, type) do if list_requires_encoding?(value) do encode_list(query, value, bindings, embedded?, acc, type) diff --git a/lib/implementation.ex b/lib/implementation.ex index 496065a..090ca03 100644 --- a/lib/implementation.ex +++ b/lib/implementation.ex @@ -9,6 +9,20 @@ defmodule AshSql.Implementation do @callback repo(Ash.Resource.t(), :mutate | :read) :: module @callback expr(Ecto.Query.t(), Ash.Expr.t(), map, boolean, AshSql.Expr.ExprInfo.t(), term) :: {:ok, term, AshSql.Expr.ExprInfo.t()} | {:error, term} | :error + + @doc """ + Render a list literal that has to be encoded, i.e. one holding maps, lists or expressions. + + `AshSql.Expr` renders those as `ARRAY[...]` (or `array_to_json(ARRAY[...])`), which is + Postgres syntax. Implementations whose database has no array constructor override this to + render the list themselves. Returning `:error`, which is the default, keeps the `ARRAY[...]` + rendering. + + The value is passed after embedded resources have been dumped, so it is a plain list. + """ + @callback list_expr(Ecto.Query.t(), list(), map, boolean, AshSql.Expr.ExprInfo.t(), term) :: + {:ok, term, AshSql.Expr.ExprInfo.t()} | {:error, term} | :error + @callback simple_join_first_aggregates(Ash.Resource.t()) :: list(atom) @callback parameterized_type( @@ -38,6 +52,7 @@ defmodule AshSql.Implementation do @callback require_extension_for_citext() :: {true, String.t()} | false @callback strpos_function() :: String.t() @callback type_expr(expr :: term, type :: term) :: term + @callback ref_cast_type(type :: term) :: term @optional_callbacks determine_types: 3 @@ -49,6 +64,7 @@ defmodule AshSql.Implementation do def strpos_function, do: "strpos" def expr(_, _, _, _, _, _), do: :error + def list_expr(_, _, _, _, _, _), do: :error def simple_join_first_aggregates(_), do: [] def list_aggregate(_), do: nil def multicolumn_distinct?, do: true @@ -59,6 +75,13 @@ defmodule AshSql.Implementation do def equals_any?, do: true def storage_type(_, _), do: nil + # The cast type to use when casting a bare column reference, as opposed + # to a value or a computed expression. Implementations can use this to + # make ref casts match the column's actual DDL type (e.g. `timestamp(0)` + # instead of `timestamp` on postgres), so the parser can collapse them + # and partial/expression indexes on the bare column remain usable. + def ref_cast_type(type), do: type + def type_expr(expr, type) do type = if Ash.Type.ash_type?(type) do @@ -80,12 +103,14 @@ defmodule AshSql.Implementation do defoverridable array_overlap_operator?: 0, equals_any?: 0, expr: 6, + list_expr: 6, ilike?: 0, strpos_function: 0, require_ash_functions_for_or_and_and?: 0, require_extension_for_citext: 0, simple_join_first_aggregates: 1, type_expr: 2, + ref_cast_type: 1, storage_type: 2, list_aggregate: 1, multicolumn_distinct?: 0 diff --git a/lib/join.ex b/lib/join.ex index f3645f5..509eb4c 100644 --- a/lib/join.ex +++ b/lib/join.ex @@ -82,8 +82,7 @@ defmodule AshSql.Join do parent_query, no_inner_join? ) do - no_inner_join? = - no_inner_join? || query.__ash_bindings__.context[:data_layer][:no_inner_join?] + no_inner_join? = left_join_only?(query, opts, no_inner_join?) case join_parent_paths(query, filter, relationship_paths) do {:ok, query} -> @@ -228,6 +227,12 @@ defmodule AshSql.Join do end end + @doc false + def left_join_only?(query, opts, explicit?) do + explicit? || query.__ash_bindings__.context[:data_layer][:no_inner_join?] || + opts[:left_only?] || false + end + @doc false def parent_expr(filter) do filter @@ -400,7 +405,8 @@ defmodule AshSql.Join do if opts[:return_subquery?] do subquery(query) else - if Enum.empty?(query.joins) && Enum.empty?(query.order_bys) && Enum.empty?(query.wheres) do + if Enum.empty?(query.joins) && Enum.empty?(query.order_bys) && Enum.empty?(query.wheres) && + Enum.empty?(query.group_bys) && is_nil(query.limit) do query else from(row in subquery(query), as: ^(opts[:start_bindings_at] || 0)) @@ -465,7 +471,10 @@ defmodule AshSql.Join do if query.__validated_for_action__ == read_action.name do query else - Ash.Query.for_read(query, read_action.name, %{}, + Ash.Query.for_read( + query, + read_action.name, + Map.get(relationship, :read_action_arguments, %{}), actor: context[:private][:actor], tenant: context[:private][:tenant] ) diff --git a/lib/query.ex b/lib/query.ex index 759af4f..7155272 100644 --- a/lib/query.ex +++ b/lib/query.ex @@ -22,6 +22,18 @@ defmodule AshSql.Query do _domain \\ nil ) do Enum.reduce(combination_of, subquery(first), fn {type, combination_of}, query -> + # Each part applies to the result of everything before it. Appending + # straight onto the accumulated query would build one flat chain of set + # operations, and SQL's own precedence would then decide the grouping — + # `INTERSECT` binds tighter than `UNION`/`EXCEPT`, so a part could end up + # applied to its predecessor rather than to the whole. Nesting what has + # accumulated keeps the order the parts were given in. + query = + case query do + %Ecto.SubQuery{} -> query + query -> subquery(query) + end + case type do :union -> Ecto.Query.union(query, ^combination_of) @@ -99,16 +111,8 @@ defmodule AshSql.Query do {data, path} -> lateral_join_source_query = path |> List.first() |> elem(0) - lateral_join_source_query.resource - |> Ash.Query.set_context(%{ - :data_layer => - Map.put( - lateral_join_source_query.context[:data_layer] || %{}, - :no_inner_join?, - true - ) - |> Map.delete(:lateral_join_source) - }) + lateral_join_source_query + |> rebuild_lateral_join_source_query() |> Ash.Query.set_tenant(lateral_join_source_query.tenant) |> set_lateral_join_prefix(data_layer_query) |> filter_for_records(data) @@ -225,6 +229,18 @@ defmodule AshSql.Query do end end + @doc false + def rebuild_lateral_join_source_query(lateral_join_source_query) do + lateral_join_source_query.resource + |> Ash.Query.set_context(Map.take(lateral_join_source_query.context, [:shared])) + |> Ash.Query.set_context(%{ + data_layer: + (lateral_join_source_query.context[:data_layer] || %{}) + |> Map.put(:no_inner_join?, true) + |> Map.delete(:lateral_join_source) + }) + end + defp filter_for_records(query, records) do keys = case Ash.Resource.Info.primary_key(query.resource) do @@ -549,7 +565,7 @@ defmodule AshSql.Query do | select: %{select | expr: {:merge, [], [merge_base, {:%{}, [], merging}]}} } |> Map.update!(:__ash_bindings__, fn bindings -> - Map.update(bindings, :select_calculations, [], &(&1 -- [:calculations])) + Map.update(bindings, :select_calculations, [], &((&1 || []) -- [:calculations])) end) {calculation_merges, aggregate_merges, new_query} diff --git a/lib/sort.ex b/lib/sort.ex index 359246e..4776a3f 100644 --- a/lib/sort.ex +++ b/lib/sort.ex @@ -43,52 +43,15 @@ defmodule AshSql.Sort do {calculation, val} %Ash.Resource.Aggregate{} = aggregate -> - related = Ash.Resource.Info.related(resource, aggregate.relationship_path) - - read_action = - aggregate.read_action || - Ash.Resource.Info.primary_action!( - related, - :read - ).name - - with %{valid?: true} = aggregate_query <- Ash.Query.for_read(related, read_action), - %{valid?: true} = aggregate_query <- - Ash.Query.build(aggregate_query, - filter: aggregate.filter, - sort: aggregate.sort - ) do - case Ash.Query.Aggregate.new( - resource, - aggregate.name, - aggregate.kind, - path: aggregate.relationship_path, - query: aggregate_query, - field: aggregate.field, - default: aggregate.default, - filterable?: aggregate.filterable?, - type: aggregate.type, - sortable?: aggregate.filterable?, - include_nil?: aggregate.include_nil?, - constraints: aggregate.constraints, - implementation: aggregate.implementation, - uniq?: aggregate.uniq?, - read_action: - aggregate.read_action || - Ash.Resource.Info.primary_action!( - Ash.Resource.Info.related(resource, aggregate.relationship_path), - :read - ).name, - authorize?: aggregate.authorize? - ) do - {:ok, agg} -> - {agg, val} - - {:error, error} -> - raise Ash.Error.to_ash_error(error) - end - else - %{errors: errors} -> raise Ash.Error.to_ash_error(errors) + case AshSql.Aggregate.resource_aggregate_to_aggregate(resource, aggregate, + actor: query.__ash_bindings__.context[:private][:actor], + tenant: query.__ash_bindings__.context[:private][:tenant] + ) do + {:ok, agg} -> + {agg, val} + + {:error, error} -> + raise Ash.Error.to_ash_error(error) end _ -> diff --git a/mix.exs b/mix.exs index 3bcbee6..c0ec3ce 100644 --- a/mix.exs +++ b/mix.exs @@ -10,7 +10,7 @@ defmodule AshSql.MixProject do Shared utilities for ecto-based sql data layers. """ - @version "0.6.5" + @version "0.7.0" def project do [ @@ -80,7 +80,7 @@ defmodule AshSql.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, ash_version("~> 3.24 and >= 3.24.5")}, + {:ash, ash_version("~> 3.32")}, {:ecto_sql, "~> 3.9"}, {:ecto, "~> 3.13 and >= 3.13.4"}, # dev/test dependencies diff --git a/mix.lock b/mix.lock index f259ec6..a412880 100644 --- a/mix.lock +++ b/mix.lock @@ -1,55 +1,55 @@ %{ - "ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"}, - "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, + "ash": {:hex, :ash, "3.32.0", "046ec1b817b026aefb0a04013d340fec86ec4ceabb3f11517332edaaa7f89dca", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ee80eeacaf5fa8995cc875741bc3f248ccd77a3e68a0927a086d9c7ed562e926"}, + "benchee": {:hex, :benchee, "1.5.1", "b95cbc36c4b98969a5c592a246e171041eb683c56bad1cb4f49a3b081ba66087", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a539301f8dfd4efc5c5123bfb9d47ebde20092a863a5b5b16c2a60d2243dfce7"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, - "crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "crux": {:hex, :crux, "0.1.4", "1fa21f5ca886d3498f83871a3ef19379b80abf8cfcf2ed32007e80279e714f1e", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "ff7d880cf732d82360aa81e439b8d4831cd30768061c9233f90c97e10162b9c0"}, "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, - "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, + "deep_merge": {:hex, :deep_merge, "1.0.2", "476aa7ea61c54de96220051b998d893869069094da65b96101aebf79416f8a1e", [:mix], [], "hexpm", "737a53cdc9758fedbb608bdc213969e65729466c4ef3cd8e8726d0335dff116c"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, - "ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"}, + "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"}, "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, - "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, + "ex_ast": {:hex, :ex_ast, "0.13.1", "b3d80ec163733176f63662ac44d2511445c224f6b5e4e3ce01f5eff83c4a5993", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "bd15f68cde5ec945b859bd67416f26cf5499f1aef9b067eb2163ed244c7e703a"}, "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, + "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_ops": {:hex, :git_ops, "2.10.0", "225780d8dcf9ef3393d26fa8d41d6a454a71149393c040e4b708c7c0b9c2b0f1", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "acd4a542eb425a58ce54505de69be704af3d0ef23c9b8cf9a3299d88e5d098ae"}, - "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"}, + "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "igniter": {:hex, :igniter, "0.8.3", "9de74d3885efae43b0b58dc6f7b816963c4bbd391e6b6fe6922ee21c4e384c76", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "afc5e3848d885e680da5c3b65e5e7717555a08cd12305190ff2be76427af39ff"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, + "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, - "reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"}, - "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"}, + "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, + "reactor": {:hex, :reactor, "1.0.6", "546a87255693bcee99451d022cc86927161cdb527f5216d4dcac2b31e08eb122", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "e3f8fd8e870c2b011316ca2ac422bb4bc710cac9f02c578f8526f7a6348d932b"}, + "req": {:hex, :req, "0.7.3", "b141f1b465dabc5fb8ce67bd2f15a85fc80f6c75719910699560e32ce49f62ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "73b303030dccc2b6d023ee5ada380825ab3a7cd3863aead493db09ec420ffdf2"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "simple_sat": {:hex, :simple_sat, "0.1.4", "39baf72cdca14f93c0b6ce2b6418b72bbb67da98fa9ca4384e2f79bbc299899d", [:mix], [], "hexpm", "3569b68e346a5fd7154b8d14173ff8bcc829f2eb7b088c30c3f42a383443930b"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, - "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, - "spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"}, - "spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"}, - "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, - "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, - "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, + "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, + "spark": {:hex, :spark, "2.7.2", "36becc6ff03b40908cc821d403d7f06d893498e293d2f718afc6ca097fcb9d93", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "adb323ddbf9dbbe326f9e5def54ac96c47911e852b2c270bb19a5147c56f1b45"}, + "spitfire": {:hex, :spitfire, "0.4.0", "6d98c10cf585434b9439ba0c6dd3cc7aeff0e06ab73bfe5488f42e7c0f883d9b", [:mix], [], "hexpm", "7e5c6d1523c111b59f332f9dc49edc0377111d0c17167a29830f0e98233f5472"}, + "splode": {:hex, :splode, "0.3.2", "7716b6b2260a98a6f018c65cc0393da2cdf17314202eb351a0956e8762190dff", [:mix], [], "hexpm", "08fd658f80da7f1cd254b149164dcff8acd44b22f032001d5416b97223d32bc9"}, + "statistex": {:hex, :statistex, "1.1.1", "73612aa7f79e53c30569be065fd121e380f1cf57bc4c2da5b41be9246da18df9", [:mix], [], "hexpm", "310c4b49b34adf683de3103639006bed233ab54c08a4add65a531448e653857c"}, + "stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, - "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"}, + "ymlr": {:hex, :ymlr, "5.1.6", "247479328fd5ea1e222ee59ecd5571ab2885cf1a6329aea458d4944cd035cfc1", [:mix], [], "hexpm", "b36d7c72384d2cb2094d2a3c8b0c3b12303e0ccd1b7b12333b81d75b1fe0b53a"}, } diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs new file mode 100644 index 0000000..691f6a9 --- /dev/null +++ b/test/aggregate_test.exs @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2024 ash_sql contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSql.AggregateTest do + use ExUnit.Case, async: true + + defmodule Comment do + use Ash.Resource, domain: AshSql.AggregateTest.Domain, data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key(:id) + attribute(:post_id, :uuid) + attribute(:score, :integer) + end + + actions do + read :read_all do + primary?(true) + end + end + end + + defmodule Post do + use Ash.Resource, domain: AshSql.AggregateTest.Domain, data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key(:id) + end + + relationships do + has_many(:comments, Comment, destination_attribute: :post_id) + end + + aggregates do + max(:highest_score, :comments, :score) + end + + actions do + defaults([:read]) + end + end + + defmodule Domain do + use Ash.Domain, validate_config_inclusion?: false + + authorization do + require_actor?(true) + end + + resources do + resource(Post) + resource(Comment) + end + end + + defp build(opts) do + AshSql.Aggregate.resource_aggregate_to_aggregate( + Post, + Ash.Resource.Info.aggregate(Post, :highest_score), + opts + ) + end + + describe "resource_aggregate_to_aggregate/3" do + test "passes actor and tenant to the related read which may require an actor" do + actor = %{id: Ash.UUID.generate()} + + assert {:ok, aggregate} = build(actor: actor, tenant: "acme") + + assert aggregate.query.context.private.actor == actor + assert aggregate.query.tenant == "acme" + end + + test "reads the related resource through its primary read action" do + assert {:ok, aggregate} = build(actor: %{id: Ash.UUID.generate()}) + + assert aggregate.query.resource == Comment + assert aggregate.read_action == :read_all + end + end +end diff --git a/test/join_test.exs b/test/join_test.exs new file mode 100644 index 0000000..8e298f9 --- /dev/null +++ b/test/join_test.exs @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2024 ash_sql contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSql.JoinTest do + use ExUnit.Case, async: true + + alias AshSql.Join + + defp query(data_layer_context \\ %{}) do + %{__ash_bindings__: %{context: %{data_layer: data_layer_context}}} + end + + describe "left_join_only?/3" do + test "a select-context join is never inner joined" do + assert Join.left_join_only?(query(), [left_only?: true], false) + end + + test "a filter-context join may still be inner joined" do + refute Join.left_join_only?(query(), [], false) + end + + test "the data layer context can force left joins" do + assert Join.left_join_only?(query(%{no_inner_join?: true}), [], false) + end + + test "an explicit no_inner_join? forces left joins" do + assert Join.left_join_only?(query(), [], true) + end + + test "returns a boolean rather than nil" do + assert Join.left_join_only?(query(), [], false) == false + end + end +end diff --git a/test/query_test.exs b/test/query_test.exs new file mode 100644 index 0000000..848d62a --- /dev/null +++ b/test/query_test.exs @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: 2024 ash_sql contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSql.QueryTest do + use ExUnit.Case, async: false + + defmodule RecordSharedContext do + use Ash.Resource.Preparation + + @impl true + def prepare(query, _opts, context) do + send(self(), {:shared_context, context.source_context[:shared]}) + query + end + end + + defmodule AuthorPreferences do + use Ash.Resource, domain: AshSql.QueryTest.Domain + + attributes do + uuid_primary_key(:id) + attribute(:favorite_topic_id, :uuid) + end + + preparations do + prepare(AshSql.QueryTest.RecordSharedContext) + end + + actions do + defaults([:read]) + end + end + + defmodule Author do + use Ash.Resource, domain: AshSql.QueryTest.Domain + + attributes do + uuid_primary_key(:id) + end + + relationships do + has_one(:preferences, AshSql.QueryTest.AuthorPreferences, + destination_attribute: :id, + source_attribute: :id + ) + end + + actions do + defaults([:read]) + end + end + + defmodule Comment do + use Ash.Resource, domain: AshSql.QueryTest.Domain + + attributes do + uuid_primary_key(:id) + attribute(:post_id, :uuid) + attribute(:topic_id, :uuid) + end + + actions do + defaults([:read]) + end + end + + defmodule Post do + use Ash.Resource, domain: AshSql.QueryTest.Domain + + attributes do + uuid_primary_key(:id) + attribute(:author_id, :uuid) + end + + relationships do + belongs_to(:author, AshSql.QueryTest.Author) + + has_many :comments, AshSql.QueryTest.Comment do + destination_attribute(:post_id) + filter(expr(parent(author.preferences.favorite_topic_id) == topic_id)) + end + end + + actions do + defaults([:read]) + end + end + + defmodule Domain do + use Ash.Domain, validate_config_inclusion?: false + + resources do + resource(AshSql.QueryTest.Post) + resource(AshSql.QueryTest.Comment) + resource(AshSql.QueryTest.Author) + resource(AshSql.QueryTest.AuthorPreferences) + end + end + + test "lateral join source keeps shared context for parent-path preparations" do + shared = %{current_user_id: Ash.UUID.generate()} + + source_query = + Post + |> Ash.Query.new() + |> Ash.Query.set_context(%{shared: shared, data_layer: %{lateral_join_source: :discarded}}) + + rebuilt_query = AshSql.Query.rebuild_lateral_join_source_query(source_query) + + assert rebuilt_query.context[:shared] == shared + assert rebuilt_query.context[:data_layer][:no_inner_join?] + refute Map.has_key?(rebuilt_query.context[:data_layer], :lateral_join_source) + + relationship = Ash.Resource.Info.relationship(Post, :comments) + + assert Ash.Filter.find(relationship.filter, fn + %Ash.Query.Parent{} -> true + _ -> false + end) + + Ash.Query.for_read(AuthorPreferences, :read, %{}, context: rebuilt_query.context) + + assert_receive {:shared_context, ^shared} + end +end