├── .tool-versions ├── mix.lock.license ├── .tool-versions.license ├── test ├── test_helper.exs └── ash_sql_test.exs ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── elixir.yml ├── config └── config.exs ├── README.md ├── .gitignore ├── .check.exs ├── LICENSES └── MIT.txt ├── lib ├── ash_sql.ex ├── filter.ex ├── typed_struct_array_jsonb.ex ├── implementation.ex ├── bindings.ex ├── calculation.ex ├── aggregate_query.ex ├── distinct.ex ├── atomics.ex ├── sort.ex ├── query.ex └── join.ex ├── mix.exs ├── .credo.exs ├── mix.lock └── CHANGELOG.md /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.3.4.3 2 | elixir 1.18.4 3 | pipx 1.8.0 4 | -------------------------------------------------------------------------------- /mix.lock.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.tool-versions.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ExUnit.start() 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # Used by "mix format" 6 | [ 7 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 8 | ] 9 | -------------------------------------------------------------------------------- /test/ash_sql_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlTest do 6 | @moduledoc false 7 | use ExUnit.Case 8 | doctest AshSql 9 | end 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | --- 6 | updates: 7 | - directory: / 8 | groups: 9 | dev-dependencies: 10 | dependency-type: development 11 | production-dependencies: 12 | dependency-type: production 13 | package-ecosystem: mix 14 | schedule: 15 | day: thursday 16 | interval: monthly 17 | versioning-strategy: lockfile-only 18 | version: 2 19 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: CI 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | branches: [main] 11 | pull_request: 12 | branches: [main] 13 | jobs: 14 | ash-ci: 15 | uses: ash-project/ash/.github/workflows/ash-ci.yml@main 16 | secrets: 17 | hex_api_key: ${{ secrets.HEX_API_KEY }} 18 | with: 19 | spark-formatter: false 20 | reuse: true 21 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import Config 6 | 7 | if Mix.env() == :dev do 8 | config :git_ops, 9 | mix_project: AshSql.MixProject, 10 | github_handle_lookup?: true, 11 | changelog_file: "CHANGELOG.md", 12 | repository_url: "https://github.com/ash-project/ash_sql", 13 | # Instructs the tool to manage your mix version in your `mix.exs` file 14 | # See below for more information 15 | manage_mix_version?: true, 16 | # Instructs the tool to manage the version in your README.md 17 | # Pass in `true` to use `"README.md"` or a string to customize 18 | manage_readme_version: ["README.md"], 19 | version_tag_prefix: "v" 20 | end 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | ![Elixir CI](https://github.com/ash-project/ash_sql/workflows/CI/badge.svg) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 8 | [![Hex version badge](https://img.shields.io/hexpm/v/ash_sql.svg)](https://hex.pm/packages/ash_sql) 9 | [![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/ash_sql) 10 | [![REUSE status](https://api.reuse.software/badge/github.com/ash-project/ash_sql)](https://api.reuse.software/info/github.com/ash-project/ash_sql) 11 | 12 | 13 | # AshSql 14 | 15 | Shared functionality for ecto-based sql data layers. 16 | 17 | ## Installation 18 | 19 | ```elixir 20 | def deps do 21 | [ 22 | {:ash_sql, "~> 0.3.15"} 23 | ] 24 | end 25 | ``` 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where third-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | ash_sql-*.tar 28 | 29 | # Temporary files, for example, from tests. 30 | /tmp/ 31 | -------------------------------------------------------------------------------- /.check.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [ 6 | ## all available options with default values (see `mix check` docs for description) 7 | # parallel: true, 8 | # skipped: true, 9 | retry: false, 10 | ## list of tools (see `mix check` docs for defaults) 11 | tools: [ 12 | ## curated tools may be disabled (e.g. the check for compilation warnings) 13 | # {:compiler, false}, 14 | {:reuse, command: ["pipx", "run", "reuse", "lint", "-q"]} 15 | 16 | ## ...or adjusted (e.g. use one-line formatter for more compact credo output) 17 | # {:credo, "mix credo --format oneline"}, 18 | ## custom new tools may be added (mix tasks or arbitrary commands) 19 | # {:my_mix_task, command: "mix release", env: %{"MIX_ENV" => "prod"}}, 20 | # {:my_arbitrary_tool, command: "npm test", cd: "assets"}, 21 | # {:my_arbitrary_script, command: ["my_script", "argument with spaces"], cd: "scripts"} 22 | ] 23 | ] 24 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /lib/ash_sql.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql do 6 | @moduledoc false 7 | def dynamic_repo(resource, sql_behaviour, %{ 8 | __ash_bindings__: %{context: %{data_layer: %{repo: repo}}} 9 | }) do 10 | repo || sql_behaviour.repo(resource, :read) 11 | end 12 | 13 | def dynamic_repo(resource, sql_behaviour, %struct{context: %{data_layer: %{repo: repo}}}) do 14 | type = struct_to_repo_type(struct) 15 | 16 | repo || sql_behaviour.repo(resource, type) 17 | end 18 | 19 | def dynamic_repo(resource, sql_behaviour, %struct{}) do 20 | sql_behaviour.repo(resource, struct_to_repo_type(struct)) 21 | end 22 | 23 | def repo_opts(_repo, sql_behaviour, timeout, tenant, resource) do 24 | if Ash.Resource.Info.multitenancy_strategy(resource) == :context do 25 | [prefix: tenant] 26 | else 27 | if schema = sql_behaviour.schema(resource) do 28 | [prefix: schema] 29 | else 30 | [] 31 | end 32 | end 33 | |> add_timeout(timeout) 34 | end 35 | 36 | defp add_timeout(opts, timeout) when not is_nil(timeout) do 37 | Keyword.put(opts, :timeout, timeout) 38 | end 39 | 40 | defp add_timeout(opts, _), do: opts 41 | 42 | defp struct_to_repo_type(struct) do 43 | case struct do 44 | Ash.Changeset -> :mutate 45 | Ash.Query -> :read 46 | Ecto.Query -> :read 47 | Ecto.Changeset -> :mutate 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/filter.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.Filter do 6 | @moduledoc false 7 | 8 | require Ecto.Query 9 | 10 | def filter(query, filter, resource, opts \\ []) do 11 | used_aggregates = Ash.Filter.used_aggregates(filter, []) 12 | 13 | query 14 | |> AshSql.Join.join_all_relationships(filter, opts) 15 | |> case do 16 | {:ok, query} -> 17 | query 18 | |> AshSql.Aggregate.add_aggregates( 19 | used_aggregates, 20 | resource, 21 | false, 22 | query.__ash_bindings__.root_binding 23 | ) 24 | |> case do 25 | {:ok, query} -> 26 | {:ok, add_filter_expression(query, filter)} 27 | 28 | {:error, error} -> 29 | {:error, error} 30 | end 31 | 32 | {:error, error} -> 33 | {:error, error} 34 | end 35 | end 36 | 37 | def add_filter_expression(query, filter) do 38 | filter 39 | |> AshSql.Expr.split_statements(:and) 40 | |> Enum.reduce(query, fn filter, query -> 41 | {dynamic, acc} = 42 | AshSql.Expr.dynamic_expr( 43 | query, 44 | filter, 45 | Map.put(query.__ash_bindings__, :location, :filter) 46 | ) 47 | 48 | dynamic = 49 | if is_nil(dynamic) do 50 | false 51 | else 52 | dynamic 53 | end 54 | 55 | query 56 | |> Ecto.Query.where([], ^dynamic) 57 | |> AshSql.Expr.merge_accumulator(acc) 58 | end) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/typed_struct_array_jsonb.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.TypedStructArrayJsonb do 6 | @moduledoc """ 7 | A custom type for handling arrays of typed structs stored as JSONB in Postgres. 8 | 9 | This type behaves similarly to `Ash.Type.Map` but expects a list of maps 10 | (dumped typed structs) instead of a single map. It's used internally by 11 | AshSql to properly cast typed struct arrays when stored as JSONB. 12 | """ 13 | use Ash.Type 14 | 15 | @impl true 16 | def constraints, do: [] 17 | 18 | @impl true 19 | def storage_type(_), do: :map 20 | 21 | @impl true 22 | def matches_type?(v, _constraints) do 23 | is_list(v) && Enum.all?(v, &is_map/1) 24 | end 25 | 26 | @impl true 27 | def cast_input(nil, _), do: {:ok, nil} 28 | def cast_input([], _), do: {:ok, []} 29 | 30 | def cast_input(value, _) when is_list(value) do 31 | if Enum.all?(value, &is_map/1) do 32 | {:ok, value} 33 | else 34 | :error 35 | end 36 | end 37 | 38 | def cast_input(_, _), do: :error 39 | 40 | @impl true 41 | def cast_stored(nil, _), do: {:ok, nil} 42 | def cast_stored([], _), do: {:ok, []} 43 | 44 | def cast_stored(value, _) when is_list(value) do 45 | if Enum.all?(value, &is_map/1) do 46 | {:ok, value} 47 | else 48 | :error 49 | end 50 | end 51 | 52 | def cast_stored(_, _), do: :error 53 | 54 | @impl true 55 | def dump_to_native(nil, _), do: {:ok, nil} 56 | def dump_to_native([], _), do: {:ok, []} 57 | 58 | def dump_to_native(value, _) when is_list(value) do 59 | if Enum.all?(value, &is_map/1) do 60 | {:ok, value} 61 | else 62 | :error 63 | end 64 | end 65 | 66 | def dump_to_native(_, _), do: :error 67 | end 68 | -------------------------------------------------------------------------------- /lib/implementation.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.Implementation do 6 | @moduledoc false 7 | @callback table(Ash.Resource.t()) :: String.t() 8 | @callback schema(Ash.Resource.t()) :: String.t() | nil 9 | @callback repo(Ash.Resource.t(), :mutate | :read) :: module 10 | @callback expr(Ecto.Query.t(), Ash.Expr.t(), map, boolean, AshSql.Expr.ExprInfo.t(), term) :: 11 | {:ok, term, AshSql.Expr.ExprInfo.t()} | {:error, term} | :error 12 | @callback simple_join_first_aggregates(Ash.Resource.t()) :: list(atom) 13 | 14 | @callback parameterized_type( 15 | Ash.Type.t() | Ecto.Type.t(), 16 | constraints :: Keyword.t() 17 | ) :: 18 | term 19 | 20 | @callback storage_type(resource :: Ash.Resource.t(), field :: atom()) :: nil | term 21 | 22 | @callback ilike?() :: boolean() 23 | @callback equals_any?() :: boolean() 24 | @callback array_overlap_operator?() :: boolean() 25 | 26 | @callback determine_types(module, list(term)) :: {list(term), term} | list(term) 27 | @callback determine_types(module, list(term), returns :: term) :: 28 | {list(term), term} | list(term) 29 | 30 | @callback list_aggregate(Ash.Resource.t()) :: String.t() | nil 31 | 32 | @callback multicolumn_distinct?() :: boolean 33 | 34 | @callback manual_relationship_function() :: atom 35 | @callback manual_relationship_subquery_function() :: atom 36 | 37 | @callback require_ash_functions_for_or_and_and?() :: boolean 38 | @callback require_extension_for_citext() :: {true, String.t()} | false 39 | @callback strpos_function() :: String.t() 40 | @callback type_expr(expr :: term, type :: term) :: term 41 | 42 | @optional_callbacks determine_types: 3 43 | 44 | defmacro __using__(_) do 45 | quote do 46 | @behaviour AshSql.Implementation 47 | require Ecto.Query 48 | 49 | def strpos_function, do: "strpos" 50 | 51 | def expr(_, _, _, _, _, _), do: :error 52 | def simple_join_first_aggregates(_), do: [] 53 | def list_aggregate(_), do: nil 54 | def multicolumn_distinct?, do: true 55 | def require_ash_functions_for_or_and_and?, do: false 56 | def require_extension_for_citext, do: false 57 | def array_overlap_operator?, do: true 58 | def ilike?, do: true 59 | def equals_any?, do: true 60 | def storage_type(_, _), do: nil 61 | 62 | def type_expr(expr, type) do 63 | type = 64 | if Ash.Type.ash_type?(type) do 65 | parameterized_type(type, []) 66 | else 67 | type 68 | end 69 | 70 | Ecto.Query.dynamic(type(^expr, ^type)) 71 | end 72 | 73 | defoverridable array_overlap_operator?: 0, 74 | equals_any?: 0, 75 | expr: 6, 76 | ilike?: 0, 77 | strpos_function: 0, 78 | require_ash_functions_for_or_and_and?: 0, 79 | require_extension_for_citext: 0, 80 | simple_join_first_aggregates: 1, 81 | type_expr: 2, 82 | storage_type: 2, 83 | list_aggregate: 1, 84 | multicolumn_distinct?: 0 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.MixProject do 6 | @moduledoc false 7 | use Mix.Project 8 | 9 | @description """ 10 | Shared utilities for ecto-based sql data layers. 11 | """ 12 | 13 | @version "0.3.15" 14 | 15 | def project do 16 | [ 17 | app: :ash_sql, 18 | version: @version, 19 | description: @description, 20 | elixir: "~> 1.13", 21 | start_permanent: Mix.env() == :prod, 22 | aliases: aliases(), 23 | deps: deps(), 24 | docs: docs(), 25 | package: package() 26 | ] 27 | end 28 | 29 | defp package do 30 | [ 31 | maintainers: [ 32 | "Zach Daniel " 33 | ], 34 | licenses: ["MIT"], 35 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* 36 | CHANGELOG*), 37 | links: %{ 38 | "GitHub" => "https://github.com/ash-project/ash_sql", 39 | "Changelog" => "https://github.com/ash-project/ash_sql/blob/main/CHANGELOG.md", 40 | "Discord" => "https://discord.gg/HTHRaaVPUc", 41 | "Website" => "https://ash-hq.org", 42 | "Forum" => "https://elixirforum.com/c/elixir-framework-forums/ash-framework-forum", 43 | "REUSE Compliance" => "https://api.reuse.software/info/github.com/ash-project/ash_sql" 44 | } 45 | ] 46 | end 47 | 48 | defp docs do 49 | [ 50 | main: "readme", 51 | source_ref: "v#{@version}", 52 | before_closing_head_tag: fn type -> 53 | if type == :html do 54 | """ 55 | 64 | """ 65 | end 66 | end, 67 | extras: [ 68 | "README.md" 69 | ] 70 | ] 71 | end 72 | 73 | # Run "mix help compile.app" to learn about applications. 74 | def application do 75 | [ 76 | extra_applications: [:logger] 77 | ] 78 | end 79 | 80 | # Run "mix help deps" to learn about dependencies. 81 | defp deps do 82 | [ 83 | {:ash, ash_version("~> 3.7")}, 84 | {:ecto_sql, "~> 3.9"}, 85 | {:ecto, "~> 3.13 and >= 3.13.4"}, 86 | # dev/test dependencies 87 | {:igniter, "~> 0.5", only: [:dev, :test]}, 88 | {:simple_sat, "~> 0.1", only: [:dev, :test]}, 89 | {:benchee, "~> 1.1", only: [:dev, :test]}, 90 | {:git_ops, "~> 2.5", only: [:dev, :test]}, 91 | {:ex_doc, "~> 0.32", only: [:dev, :test], runtime: false}, 92 | {:ex_check, "~> 0.14", only: [:dev, :test]}, 93 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 94 | {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, 95 | {:sobelow, ">= 0.0.0", only: [:dev, :test], runtime: false}, 96 | {:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false} 97 | ] 98 | end 99 | 100 | defp ash_version(default_version) do 101 | case System.get_env("ASH_VERSION") do 102 | nil -> 103 | default_version 104 | 105 | "local" -> 106 | [path: "../ash", override: true] 107 | 108 | "main" -> 109 | [git: "https://github.com/ash-project/ash.git", override: true] 110 | 111 | version when is_binary(version) -> 112 | "~> #{version}" 113 | 114 | version -> 115 | version 116 | end 117 | end 118 | 119 | defp aliases do 120 | [ 121 | sobelow: ["sobelow --skip"] 122 | ] 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/bindings.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.Bindings do 6 | @moduledoc false 7 | 8 | @doc false 9 | def add_binding(query, data, additional_bindings \\ 0) do 10 | current = query.__ash_bindings__.current 11 | bindings = query.__ash_bindings__.bindings 12 | 13 | new_ash_bindings = %{ 14 | query.__ash_bindings__ 15 | | bindings: Map.put(bindings, current, data), 16 | current: current + 1 + additional_bindings 17 | } 18 | 19 | %{query | __ash_bindings__: new_ash_bindings} 20 | end 21 | 22 | def explicitly_set_binding(query, data, to) do 23 | %{ 24 | query 25 | | __ash_bindings__: %{ 26 | query.__ash_bindings__ 27 | | bindings: Map.put(query.__ash_bindings__.bindings, to, data) 28 | } 29 | } 30 | end 31 | 32 | def merge_expr_accumulator(query, acc) do 33 | update_in( 34 | query.__ash_bindings__.expression_accumulator, 35 | &AshSql.Expr.merge_accumulator(&1, acc) 36 | ) 37 | end 38 | 39 | def default_bindings(query, resource, sql_behaviour, context \\ %{}) 40 | 41 | def default_bindings(%{__ash_bindings__: _} = query, _resource, _sql_behaviour, _context), 42 | do: query 43 | 44 | def default_bindings(query, resource, sql_behaviour, context) do 45 | default_start_bindings = 46 | if context[:data_layer][:lateral_join_source] do 47 | 500 48 | else 49 | 0 50 | end 51 | 52 | requested_start = context[:data_layer][:start_bindings_at] || 0 53 | 54 | start_bindings = 55 | if requested_start == 0 do 56 | default_start_bindings 57 | else 58 | requested_start 59 | end 60 | 61 | Map.put_new(query, :__ash_bindings__, %{ 62 | resource: resource, 63 | sql_behaviour: sql_behaviour, 64 | current: Enum.count(query.joins) + 1 + start_bindings, 65 | expression_accumulator: %AshSql.Expr.ExprInfo{}, 66 | in_group?: false, 67 | calculations: %{}, 68 | parent_resources: [], 69 | aggregate_defs: %{}, 70 | current_aggregate_name: :aggregate_0, 71 | current_calculation_name: :calculation_0, 72 | aggregate_names: %{}, 73 | calculation_names: %{}, 74 | context: context, 75 | root_binding: start_bindings, 76 | bindings: %{start_bindings => %{path: [], type: :root, source: resource}} 77 | }) 78 | end 79 | 80 | @doc false 81 | def get_binding(resource, candidate_path, %{__ash_bindings__: _} = query, types) do 82 | types = List.wrap(types) 83 | 84 | Enum.find_value(query.__ash_bindings__.bindings, fn 85 | {binding, %{path: path, source: source, type: type}} -> 86 | if type in types && 87 | Ash.Resource.Info.synonymous_relationship_paths?( 88 | resource, 89 | path, 90 | candidate_path, 91 | source 92 | ) do 93 | binding 94 | end 95 | 96 | _ -> 97 | nil 98 | end) 99 | end 100 | 101 | def get_binding(_, _, _, _), do: nil 102 | 103 | def add_parent_bindings(data_layer_query, %{data_layer: %{parent_bindings: parent_bindings}}) 104 | when not is_nil(parent_bindings) do 105 | new_bindings = 106 | data_layer_query.__ash_bindings__ 107 | |> Map.put(:parent_bindings, Map.put(parent_bindings, :parent?, true)) 108 | |> Map.put(:parent_resources, [ 109 | parent_bindings.resource | parent_bindings[:parent_resources] || [] 110 | ]) 111 | |> Map.put(:lateral_join?, true) 112 | 113 | %{data_layer_query | __ash_bindings__: new_bindings} 114 | end 115 | 116 | def add_parent_bindings(data_layer_query, _context) do 117 | data_layer_query 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/calculation.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.Calculation do 6 | @moduledoc false 7 | 8 | require Ecto.Query 9 | 10 | @next_calculation_names Enum.reduce(0..999, %{}, fn i, acc -> 11 | Map.put(acc, :"calculation_#{i}", :"calculation_#{i + 1}") 12 | end) 13 | 14 | def add_calculations(query, calculations, resource, source_binding, select? \\ false) 15 | def add_calculations(query, [], _, _, _select?), do: {:ok, query} 16 | 17 | def add_calculations(query, calculations, resource, source_binding, select?) do 18 | {:ok, query} = 19 | AshSql.Join.join_all_relationships( 20 | query, 21 | %Ash.Filter{ 22 | resource: resource, 23 | expression: Enum.map(calculations, &elem(&1, 1)) 24 | }, 25 | left_only?: true 26 | ) 27 | 28 | aggregates = 29 | calculations 30 | |> Enum.flat_map(fn {calculation, expression} -> 31 | expression 32 | |> Ash.Filter.used_aggregates([]) 33 | |> Enum.map(&Map.put(&1, :context, calculation.context)) 34 | end) 35 | |> Enum.uniq() 36 | 37 | {query, calculations} = 38 | Enum.reduce( 39 | calculations, 40 | {query, []}, 41 | fn {calculation, expression}, {query, calculations} -> 42 | if is_atom(calculation.name) do 43 | {query, [{calculation, expression} | calculations]} 44 | else 45 | {query, name} = use_calculation_name(query, calculation.name) 46 | 47 | {query, [{%{calculation | name: name}, expression} | calculations]} 48 | end 49 | end 50 | ) 51 | 52 | case AshSql.Aggregate.add_aggregates( 53 | query, 54 | aggregates, 55 | query.__ash_bindings__.resource, 56 | false, 57 | source_binding 58 | ) do 59 | {:ok, query} -> 60 | combinations? = query.__ash_bindings__.context[:data_layer][:combination_query?] 61 | 62 | if select? || combinations? do 63 | query = 64 | if query.select do 65 | query 66 | else 67 | Ecto.Query.select_merge(query, %{}) 68 | end 69 | 70 | {dynamics, query} = 71 | Enum.reduce(calculations, {[], query}, fn {calculation, expression}, {list, query} -> 72 | expression = 73 | Ash.Actions.Read.add_calc_context_to_filter( 74 | expression, 75 | calculation.context.actor, 76 | calculation.context.authorize?, 77 | calculation.context.tenant, 78 | calculation.context.tracer, 79 | query.__ash_bindings__[:domain], 80 | query.__ash_bindings__[:resource], 81 | parent_stack: query.__ash_bindings__[:parent_resources] || [] 82 | ) 83 | 84 | expression = 85 | if calculation.context.type do 86 | case expression do 87 | %Ash.Query.Function.Type{arguments: [expression | _]} -> 88 | expression 89 | 90 | %Ash.Query.Call{name: :type, args: [expression | _]} -> 91 | expression 92 | 93 | _ -> 94 | expression 95 | end 96 | else 97 | expression 98 | end 99 | 100 | expression = 101 | if is_nil(calculation.context.type) || 102 | map_type?(calculation.context.type, calculation.context.constraints || []) do 103 | expression 104 | else 105 | {:ok, expression} = 106 | Ash.Query.Function.Type.new([ 107 | expression, 108 | calculation.context.type, 109 | calculation.context.constraints || [] 110 | ]) 111 | 112 | expression 113 | end 114 | 115 | {expression, acc} = 116 | AshSql.Expr.dynamic_expr( 117 | query, 118 | expression, 119 | Map.put(query.__ash_bindings__, :location, :select), 120 | false, 121 | {calculation.type, Map.get(calculation, :constraints, [])} 122 | ) 123 | 124 | load = 125 | if combinations? do 126 | calculation.name 127 | else 128 | calculation.load 129 | end 130 | 131 | {[{load, calculation.name, expression} | list], 132 | AshSql.Expr.merge_accumulator(query, acc)} 133 | end) 134 | 135 | {:ok, add_calculation_selects(query, dynamics)} 136 | else 137 | {:ok, query} 138 | end 139 | 140 | {:error, error} -> 141 | {:error, error} 142 | end 143 | end 144 | 145 | def next_calculation_name(i) do 146 | @next_calculation_names[i] || 147 | raise Ash.Error.Framework.AssumptionFailed, 148 | message: """ 149 | All 1000 static names for calculations have been used in a single query. 150 | Congratulations, this means that you have gone so wildly beyond our imagination 151 | of how much can fit into a single quer. Please file an issue and we will raise the limit. 152 | """ 153 | end 154 | 155 | @doc false 156 | def map_type?({:array, type}, constraints) do 157 | map_type?(type, constraints[:items] || []) 158 | end 159 | 160 | def map_type?(type, constraints) when type in [:map, Ash.Type.Map] do 161 | !Keyword.has_key?(constraints, :fields) 162 | end 163 | 164 | def map_type?(type, constraints) do 165 | if Ash.Type.NewType.new_type?(type) do 166 | constraints = Ash.Type.NewType.constraints(type, constraints) 167 | type = Ash.Type.NewType.subtype_of(type) 168 | map_type?(type, constraints) 169 | else 170 | false 171 | end 172 | end 173 | 174 | defp use_calculation_name(query, aggregate_name) do 175 | {%{ 176 | query 177 | | __ash_bindings__: %{ 178 | query.__ash_bindings__ 179 | | current_calculation_name: 180 | next_calculation_name(query.__ash_bindings__.current_calculation_name), 181 | calculation_names: 182 | Map.put( 183 | query.__ash_bindings__.calculation_names, 184 | aggregate_name, 185 | query.__ash_bindings__.current_calculation_name 186 | ) 187 | } 188 | }, query.__ash_bindings__.current_calculation_name} 189 | end 190 | 191 | defp add_calculation_selects(query, dynamics) do 192 | {in_calculations, in_body} = 193 | Enum.split_with(dynamics, fn {load, _name, _dynamic} -> is_nil(load) end) 194 | 195 | calcs = 196 | in_body 197 | |> Map.new(fn {load, _, dynamic} -> 198 | {load, dynamic} 199 | end) 200 | 201 | calcs = 202 | if Enum.empty?(in_calculations) do 203 | calcs 204 | else 205 | Map.put( 206 | calcs, 207 | :calculations, 208 | Map.new(in_calculations, fn {_, name, dynamic} -> 209 | {name, dynamic} 210 | end) 211 | ) 212 | end 213 | 214 | query = Ecto.Query.select_merge(query, ^calcs) 215 | put_in(query.__ash_bindings__[:select_calculations], Map.keys(calcs)) 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # This file contains the configuration for Credo and you are probably reading 6 | # this after creating it with `mix credo.gen.config`. 7 | # 8 | # If you find anything wrong or unclear in this file, please report an 9 | # issue on GitHub: https://github.com/rrrene/credo/issues 10 | # 11 | %{ 12 | # 13 | # You can have as many configs as you like in the `configs:` field. 14 | configs: [ 15 | %{ 16 | # 17 | # Run any config using `mix credo -C `. If no config name is given 18 | # "default" is used. 19 | # 20 | name: "default", 21 | # 22 | # These are the files included in the analysis: 23 | files: %{ 24 | # 25 | # You can give explicit globs or simply directories. 26 | # In the latter case `**/*.{ex,exs}` will be used. 27 | # 28 | included: [ 29 | "lib/", 30 | "src/", 31 | "test/", 32 | "web/", 33 | "apps/*/lib/", 34 | "apps/*/src/", 35 | "apps/*/test/", 36 | "apps/*/web/" 37 | ], 38 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 39 | }, 40 | # 41 | # Load and configure plugins here: 42 | # 43 | plugins: [], 44 | # 45 | # If you create your own checks, you must specify the source files for 46 | # them here, so they can be loaded by Credo before running the analysis. 47 | # 48 | requires: [], 49 | # 50 | # If you want to enforce a style guide and need a more traditional linting 51 | # experience, you can change `strict` to `true` below: 52 | # 53 | strict: false, 54 | # 55 | # To modify the timeout for parsing files, change this value: 56 | # 57 | parse_timeout: 5000, 58 | # 59 | # If you want to use uncolored output by default, you can change `color` 60 | # to `false` below: 61 | # 62 | color: true, 63 | # 64 | # You can customize the parameters of any check by adding a second element 65 | # to the tuple. 66 | # 67 | # To disable a check put `false` as second element: 68 | # 69 | # {Credo.Check.Design.DuplicatedCode, false} 70 | # 71 | checks: [ 72 | # 73 | ## Consistency Checks 74 | # 75 | {Credo.Check.Consistency.ExceptionNames, []}, 76 | {Credo.Check.Consistency.LineEndings, []}, 77 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 78 | {Credo.Check.Consistency.SpaceAroundOperators, false}, 79 | {Credo.Check.Consistency.SpaceInParentheses, []}, 80 | {Credo.Check.Consistency.TabsOrSpaces, []}, 81 | 82 | # 83 | ## Design Checks 84 | # 85 | # You can customize the priority of any check 86 | # Priority values are: `low, normal, high, higher` 87 | # 88 | {Credo.Check.Design.AliasUsage, false}, 89 | # You can also customize the exit_status of each check. 90 | # If you don't want TODO comments to cause `mix credo` to fail, just 91 | # set this value to 0 (zero). 92 | # 93 | {Credo.Check.Design.TagTODO, false}, 94 | {Credo.Check.Design.TagFIXME, []}, 95 | 96 | # 97 | ## Readability Checks 98 | # 99 | {Credo.Check.Readability.AliasOrder, []}, 100 | {Credo.Check.Readability.FunctionNames, []}, 101 | {Credo.Check.Readability.LargeNumbers, []}, 102 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 103 | {Credo.Check.Readability.ModuleAttributeNames, []}, 104 | {Credo.Check.Readability.ModuleDoc, []}, 105 | {Credo.Check.Readability.ModuleNames, []}, 106 | {Credo.Check.Readability.ParenthesesInCondition, false}, 107 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 108 | {Credo.Check.Readability.PredicateFunctionNames, []}, 109 | {Credo.Check.Readability.PreferImplicitTry, []}, 110 | {Credo.Check.Readability.RedundantBlankLines, []}, 111 | {Credo.Check.Readability.Semicolons, []}, 112 | {Credo.Check.Readability.SpaceAfterCommas, []}, 113 | {Credo.Check.Readability.StringSigils, []}, 114 | {Credo.Check.Readability.TrailingBlankLine, []}, 115 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 116 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 117 | {Credo.Check.Readability.VariableNames, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.CondStatements, []}, 123 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 124 | {Credo.Check.Refactor.FunctionArity, [max_arity: 12]}, 125 | {Credo.Check.Refactor.LongQuoteBlocks, false}, 126 | {Credo.Check.Refactor.MapInto, []}, 127 | {Credo.Check.Refactor.MatchInCondition, []}, 128 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 129 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 130 | {Credo.Check.Refactor.Nesting, [max_nesting: 6]}, 131 | {Credo.Check.Refactor.UnlessWithElse, []}, 132 | {Credo.Check.Refactor.WithClauses, []}, 133 | 134 | # 135 | ## Warnings 136 | # 137 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 138 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 139 | {Credo.Check.Warning.IExPry, []}, 140 | {Credo.Check.Warning.IoInspect, []}, 141 | {Credo.Check.Warning.LazyLogging, []}, 142 | {Credo.Check.Warning.MixEnv, false}, 143 | {Credo.Check.Warning.OperationOnSameValues, []}, 144 | {Credo.Check.Warning.OperationWithConstantResult, []}, 145 | {Credo.Check.Warning.RaiseInsideRescue, []}, 146 | {Credo.Check.Warning.UnusedEnumOperation, []}, 147 | {Credo.Check.Warning.UnusedFileOperation, []}, 148 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 149 | {Credo.Check.Warning.UnusedListOperation, []}, 150 | {Credo.Check.Warning.UnusedPathOperation, []}, 151 | {Credo.Check.Warning.UnusedRegexOperation, []}, 152 | {Credo.Check.Warning.UnusedStringOperation, []}, 153 | {Credo.Check.Warning.UnusedTupleOperation, []}, 154 | {Credo.Check.Warning.UnsafeExec, []}, 155 | 156 | # 157 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 158 | 159 | # 160 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 161 | # 162 | {Credo.Check.Readability.StrictModuleLayout, false}, 163 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 164 | {Credo.Check.Consistency.UnusedVariableNames, false}, 165 | {Credo.Check.Design.DuplicatedCode, false}, 166 | {Credo.Check.Readability.AliasAs, false}, 167 | {Credo.Check.Readability.MultiAlias, false}, 168 | {Credo.Check.Readability.Specs, false}, 169 | {Credo.Check.Readability.SinglePipe, false}, 170 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 171 | {Credo.Check.Refactor.ABCSize, false}, 172 | {Credo.Check.Refactor.AppendSingleItem, false}, 173 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 174 | {Credo.Check.Refactor.ModuleDependencies, false}, 175 | {Credo.Check.Refactor.NegatedIsNil, false}, 176 | {Credo.Check.Refactor.PipeChainStart, false}, 177 | {Credo.Check.Refactor.VariableRebinding, false}, 178 | {Credo.Check.Warning.LeakyEnvironment, false}, 179 | {Credo.Check.Warning.MapGetUnsafePass, false}, 180 | {Credo.Check.Warning.UnsafeToAtom, false} 181 | 182 | # 183 | # Custom checks can be created using `mix credo.gen.check`. 184 | # 185 | ] 186 | } 187 | ] 188 | } 189 | -------------------------------------------------------------------------------- /lib/aggregate_query.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.AggregateQuery do 6 | @moduledoc false 7 | import Ecto.Query, only: [from: 2, subquery: 1] 8 | 9 | def run_aggregate_query(original_query, aggregates, resource, implementation) do 10 | original_query = 11 | AshSql.Bindings.default_bindings(original_query, resource, implementation) 12 | 13 | {can_group, cant_group} = 14 | aggregates 15 | |> Enum.split_with(&AshSql.Aggregate.can_group?(resource, &1, original_query)) 16 | |> case do 17 | {[one], cant_group} -> {[], [one | cant_group]} 18 | {can_group, cant_group} -> {can_group, cant_group} 19 | end 20 | 21 | {global_filter, can_group} = 22 | AshSql.Aggregate.extract_shared_filters(can_group) 23 | 24 | query = 25 | case global_filter do 26 | {:ok, global_filter} -> 27 | AshSql.Filter.filter(original_query, global_filter, resource) 28 | 29 | :error -> 30 | {:ok, original_query} 31 | end 32 | 33 | case query do 34 | {:error, error} -> 35 | {:error, error} 36 | 37 | {:ok, query} -> 38 | query = 39 | if query.distinct || query.limit do 40 | query = 41 | query 42 | |> Ecto.Query.exclude(:select) 43 | |> Ecto.Query.exclude(:order_by) 44 | |> Map.put(:windows, []) 45 | 46 | from(row in subquery(query), as: ^query.__ash_bindings__.root_binding, select: %{}) 47 | else 48 | query 49 | |> Ecto.Query.exclude(:select) 50 | |> Ecto.Query.exclude(:order_by) 51 | |> Map.put(:windows, []) 52 | |> Ecto.Query.select(%{}) 53 | end 54 | |> Map.put(:__ash_bindings__, query.__ash_bindings__) 55 | 56 | group_query = 57 | Enum.reduce( 58 | can_group, 59 | query, 60 | fn agg, query -> 61 | first_relationship = 62 | Ash.Resource.Info.relationship(resource, agg.relationship_path |> Enum.at(0)) 63 | 64 | AshSql.Aggregate.add_subquery_aggregate_select( 65 | query, 66 | agg.relationship_path |> Enum.drop(1), 67 | agg, 68 | resource, 69 | false, 70 | first_relationship 71 | ) 72 | end 73 | ) 74 | 75 | result = 76 | case can_group do 77 | [] -> 78 | %{} 79 | 80 | _ -> 81 | repo = AshSql.dynamic_repo(resource, implementation, query) 82 | repo.one(group_query, AshSql.repo_opts(repo, implementation, nil, nil, resource)) 83 | end 84 | 85 | {:ok, add_single_aggs(result, resource, query, cant_group, implementation)} 86 | end 87 | end 88 | 89 | def add_single_aggs(result, resource, query, cant_group, implementation) do 90 | Enum.reduce(cant_group, result, fn 91 | %{kind: :exists} = agg, result -> 92 | {:ok, filtered} = 93 | case agg do 94 | %{query: %{filter: filter}} when not is_nil(filter) -> 95 | AshSql.Filter.filter(query, filter, resource) 96 | 97 | _ -> 98 | {:ok, query} 99 | end 100 | 101 | filtered = 102 | if filtered.distinct || filtered.limit do 103 | filtered = 104 | filtered 105 | |> Ecto.Query.exclude(:select) 106 | |> Ecto.Query.exclude(:order_by) 107 | |> Map.put(:windows, []) 108 | 109 | from(row in subquery(filtered), as: ^query.__ash_bindings__.root_binding, select: %{}) 110 | else 111 | filtered 112 | |> Ecto.Query.exclude(:select) 113 | |> Ecto.Query.exclude(:order_by) 114 | |> Map.put(:windows, []) 115 | |> Ecto.Query.select(%{}) 116 | end 117 | 118 | repo = AshSql.dynamic_repo(resource, implementation, filtered) 119 | 120 | Map.put( 121 | result || %{}, 122 | agg.name, 123 | repo.exists?(filtered, AshSql.repo_opts(repo, implementation, nil, nil, resource)) 124 | ) 125 | 126 | agg, result -> 127 | {:ok, filtered} = 128 | case agg do 129 | %{query: %{filter: filter}} when not is_nil(filter) -> 130 | AshSql.Filter.filter(query, filter, resource) 131 | 132 | _ -> 133 | {:ok, query} 134 | end 135 | 136 | filtered = 137 | if filtered.distinct do 138 | in_query = filtered |> Ecto.Query.exclude(:distinct) |> Ecto.Query.exclude(:select) 139 | 140 | dynamic = 141 | Enum.reduce(Ash.Resource.Info.primary_key(resource), nil, fn key, dynamic -> 142 | if dynamic do 143 | Ecto.Query.dynamic( 144 | [row], 145 | ^dynamic and 146 | field(parent_as(^query.__ash_bindings__.root_binding), ^key) == 147 | field(row, ^key) 148 | ) 149 | else 150 | Ecto.Query.dynamic( 151 | [row], 152 | field(parent_as(^query.__ash_bindings__.root_binding), ^key) == 153 | field(row, ^key) 154 | ) 155 | end 156 | end) 157 | 158 | in_query = 159 | from(row in in_query, where: ^dynamic) 160 | 161 | in_query = Ecto.Query.exclude(in_query, :distinct) 162 | 163 | from(row in query.from.source, 164 | as: ^query.__ash_bindings__.root_binding, 165 | where: exists(in_query) 166 | ) 167 | else 168 | filtered 169 | end 170 | 171 | filtered = 172 | if filtered.limit do 173 | filtered = 174 | filtered 175 | |> Ecto.Query.exclude(:select) 176 | |> Ecto.Query.exclude(:order_by) 177 | |> Map.put(:windows, []) 178 | 179 | from(row in subquery(filtered), as: ^query.__ash_bindings__.root_binding, select: %{}) 180 | else 181 | filtered 182 | |> Ecto.Query.exclude(:select) 183 | |> Ecto.Query.exclude(:order_by) 184 | |> Map.put(:windows, []) 185 | |> Ecto.Query.select(%{}) 186 | end 187 | 188 | first_relationship = 189 | Ash.Resource.Info.relationship(resource, agg.relationship_path |> Enum.at(0)) 190 | 191 | filtered = AshSql.Bindings.default_bindings(filtered, resource, implementation) 192 | 193 | ref = 194 | AshSql.Aggregate.aggregate_field_ref( 195 | agg, 196 | Ash.Resource.Info.related(resource, agg.relationship_path), 197 | agg.relationship_path, 198 | filtered, 199 | first_relationship 200 | ) 201 | 202 | {:ok, filtered} = 203 | if ref do 204 | {:ok, filtered} = 205 | case ref.attribute do 206 | %struct{} = agg when struct in [Ash.Query.Aggregate, Ash.Resource.Aggregate] -> 207 | AshSql.Aggregate.add_aggregates( 208 | filtered, 209 | [agg], 210 | resource, 211 | false, 212 | filtered.__ash_bindings__.root_binding 213 | ) 214 | 215 | %Ash.Query.Calculation{} = calc -> 216 | used_aggregates = Ash.Filter.used_aggregates(calc, []) 217 | 218 | with {:ok, filtered} <- AshSql.Join.join_all_relationships(filtered, calc, []) do 219 | AshSql.Aggregate.add_aggregates( 220 | filtered, 221 | used_aggregates, 222 | resource, 223 | false, 224 | filtered.__ash_bindings__.root_binding 225 | ) 226 | end 227 | 228 | _other -> 229 | {:ok, filtered} 230 | end 231 | 232 | AshSql.Join.join_all_relationships(filtered, ref) 233 | else 234 | {:ok, filtered} 235 | end 236 | 237 | query = 238 | AshSql.Aggregate.add_subquery_aggregate_select( 239 | filtered, 240 | agg.relationship_path |> Enum.drop(1), 241 | %{agg | query: %{agg.query | filter: nil}}, 242 | resource, 243 | true, 244 | first_relationship 245 | ) 246 | 247 | repo = AshSql.dynamic_repo(resource, implementation, query) 248 | 249 | Map.merge( 250 | result || %{}, 251 | repo.one( 252 | query, 253 | AshSql.repo_opts(repo, query.__ash_bindings__.sql_behaviour, nil, nil, resource) 254 | ) 255 | ) 256 | end) 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /lib/distinct.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.Distinct do 6 | @moduledoc false 7 | require Ecto.Query 8 | import Ecto.Query, only: [from: 2] 9 | 10 | def distinct(query, empty, resource) when empty in [nil, []] do 11 | query |> AshSql.Sort.apply_sort(query.__ash_bindings__[:sort], resource) 12 | end 13 | 14 | def distinct(query, distinct_on, resource) do 15 | case get_distinct_statement(query, distinct_on) do 16 | {:ok, {distinct_statement, query}} -> 17 | %{query | distinct: distinct_statement} 18 | |> AshSql.Sort.apply_sort(query.__ash_bindings__[:sort], resource) 19 | 20 | {:error, {distinct_statement, query}} -> 21 | query 22 | |> Ecto.Query.exclude(:order_by) 23 | |> AshSql.Bindings.default_bindings(resource, query.__ash_bindings__.sql_behaviour) 24 | |> Map.put(:distinct, distinct_statement) 25 | |> AshSql.Sort.apply_sort( 26 | query.__ash_bindings__[:distinct_sort] || query.__ash_bindings__[:sort], 27 | resource, 28 | :direct 29 | ) 30 | |> case do 31 | {:ok, distinct_query} -> 32 | on = 33 | Enum.reduce(Ash.Resource.Info.primary_key(resource), nil, fn key, dynamic -> 34 | if dynamic do 35 | Ecto.Query.dynamic( 36 | [row, distinct], 37 | ^dynamic and field(row, ^key) == field(distinct, ^key) 38 | ) 39 | else 40 | Ecto.Query.dynamic([row, distinct], field(row, ^key) == field(distinct, ^key)) 41 | end 42 | end) 43 | 44 | joined_query_source = 45 | Enum.reduce( 46 | [ 47 | :join, 48 | :order_by, 49 | :group_by, 50 | :having, 51 | :distinct, 52 | :select, 53 | :combinations, 54 | :with_ctes, 55 | :limit, 56 | :offset, 57 | :lock, 58 | :preload, 59 | :update, 60 | :where 61 | ], 62 | query, 63 | &Ecto.Query.exclude(&2, &1) 64 | ) 65 | 66 | {calculations_require_rewrite, aggregates_require_rewrite, distinct_query} = 67 | AshSql.Query.rewrite_nested_selects(distinct_query) 68 | 69 | joined_query = 70 | from(row in joined_query_source, 71 | join: distinct in subquery(distinct_query), 72 | on: ^on 73 | ) 74 | 75 | from([row, distinct] in joined_query, 76 | select: distinct 77 | ) 78 | |> AshSql.Bindings.default_bindings(resource, query.__ash_bindings__.sql_behaviour) 79 | |> AshSql.Sort.apply_sort(query.__ash_bindings__[:sort], resource) 80 | |> case do 81 | {:ok, joined_query} -> 82 | {:ok, 83 | Map.update!( 84 | joined_query, 85 | :__ash_bindings__, 86 | fn ash_bindings -> 87 | ash_bindings 88 | |> Map.put(:__order__?, query.__ash_bindings__[:__order__?] || false) 89 | |> Map.put( 90 | :select_calculations, 91 | query.__ash_bindings__[:select_calculations] 92 | ) 93 | |> Map.put( 94 | :select, 95 | query.__ash_bindings__[:select] 96 | ) 97 | |> Map.update( 98 | :calculations_require_rewrite, 99 | calculations_require_rewrite, 100 | fn current_calculations_require_rewrite -> 101 | Map.merge( 102 | current_calculations_require_rewrite, 103 | calculations_require_rewrite 104 | ) 105 | end 106 | ) 107 | |> Map.update( 108 | :aggregates_require_rewrite, 109 | aggregates_require_rewrite, 110 | fn current_aggregates_require_rewrite -> 111 | Map.merge( 112 | current_aggregates_require_rewrite, 113 | aggregates_require_rewrite 114 | ) 115 | end 116 | ) 117 | end 118 | )} 119 | 120 | {:error, error} -> 121 | {:error, error} 122 | end 123 | 124 | {:error, error} -> 125 | {:error, error} 126 | end 127 | end 128 | end 129 | 130 | defp get_distinct_statement(query, distinct_on) do 131 | has_distinct_sort? = match?(%{__ash_bindings__: %{distinct_sort: _}}, query) 132 | 133 | if has_distinct_sort? do 134 | {:error, default_distinct_statement(query, distinct_on)} 135 | else 136 | sort = query.__ash_bindings__[:sort] || [] 137 | 138 | distinct = 139 | if Code.ensure_loaded?(Ecto.Query.ByExpr) do 140 | query.distinct || 141 | struct!(Ecto.Query.ByExpr, expr: [], params: []) 142 | else 143 | query.distinct || 144 | %Ecto.Query.QueryExpr{ 145 | expr: [], 146 | params: [] 147 | } 148 | end 149 | 150 | if sort == [] do 151 | {:ok, default_distinct_statement(query, distinct_on)} 152 | else 153 | distinct_on 154 | |> Enum.reduce_while({sort, [], [], Enum.count(distinct.params), query}, fn 155 | _, {[], _distinct_statement, _, _count, _query} -> 156 | {:halt, :error} 157 | 158 | distinct_on, {[order_by | rest_order_by], distinct_statement, params, count, query} -> 159 | case order_by do 160 | {distinct_on, order} = ^distinct_on -> 161 | {distinct_expr, params, count, query} = 162 | distinct_on_expr(query, distinct_on, params, count) 163 | 164 | {:cont, 165 | {rest_order_by, [{order, distinct_expr} | distinct_statement], params, count, 166 | query}} 167 | 168 | _ -> 169 | {:halt, :error} 170 | end 171 | end) 172 | |> case do 173 | :error -> 174 | {:error, default_distinct_statement(query, distinct_on)} 175 | 176 | {_, result, params, _, query} -> 177 | {:ok, 178 | {%{ 179 | distinct 180 | | expr: distinct.expr ++ Enum.reverse(result), 181 | params: distinct.params ++ Enum.reverse(params) 182 | }, query}} 183 | end 184 | end 185 | end 186 | end 187 | 188 | defp default_distinct_statement(query, distinct_on) do 189 | distinct = 190 | if Code.ensure_loaded?(Ecto.Query.ByExpr) do 191 | query.distinct || 192 | struct!(Ecto.Query.ByExpr, expr: [], params: []) 193 | else 194 | query.distinct || 195 | %Ecto.Query.QueryExpr{ 196 | expr: [], 197 | params: [] 198 | } 199 | end 200 | 201 | {expr, params, _, query} = 202 | Enum.reduce(distinct_on, {[], [], Enum.count(distinct.params), query}, fn 203 | {distinct_on_field, order}, {expr, params, count, query} -> 204 | {distinct_expr, params, count, query} = 205 | distinct_on_expr(query, distinct_on_field, params, count) 206 | 207 | {[{order, distinct_expr} | expr], params, count, query} 208 | 209 | distinct_on_field, {expr, params, count, query} -> 210 | {distinct_expr, params, count, query} = 211 | distinct_on_expr(query, distinct_on_field, params, count) 212 | 213 | {[{:asc, distinct_expr} | expr], params, count, query} 214 | end) 215 | 216 | {%{ 217 | distinct 218 | | expr: distinct.expr ++ Enum.reverse(expr), 219 | params: distinct.params ++ Enum.reverse(params) 220 | }, query} 221 | end 222 | 223 | defp distinct_on_expr(query, field, params, count) do 224 | resource = query.__ash_bindings__.resource 225 | 226 | ref = 227 | case field do 228 | %Ash.Query.Calculation{} = calc -> 229 | %Ash.Query.Ref{attribute: calc, relationship_path: [], resource: resource} 230 | 231 | field -> 232 | %Ash.Query.Ref{ 233 | attribute: Ash.Resource.Info.field(resource, field), 234 | relationship_path: [], 235 | resource: resource 236 | } 237 | end 238 | 239 | {dynamic, acc} = AshSql.Expr.dynamic_expr(query, ref, query.__ash_bindings__) 240 | 241 | result = 242 | Ecto.Query.Builder.Dynamic.partially_expand( 243 | :distinct, 244 | query, 245 | dynamic, 246 | params, 247 | count 248 | ) 249 | 250 | expr = elem(result, 0) 251 | new_params = elem(result, 1) 252 | new_count = result |> Tuple.to_list() |> List.last() 253 | 254 | {expr, new_params, new_count, AshSql.Expr.merge_accumulator(query, acc)} 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /lib/atomics.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.Atomics do 6 | require Ecto.Query 7 | 8 | def select_atomics(_resource, query, []) do 9 | {:ok, query} 10 | end 11 | 12 | # sobelow_skip ["DOS.StringToAtom"] 13 | def select_atomics(resource, query, atomics) do 14 | atomics = type_atomics(query.__ash_bindings__.sql_behaviour, resource, atomics) 15 | 16 | atomics 17 | |> Enum.reverse() 18 | |> Enum.reduce_while({:ok, query, []}, fn {field, expr}, {:ok, query, dynamics} -> 19 | attribute = Ash.Resource.Info.attribute(resource, field) 20 | 21 | expr = 22 | case expr do 23 | %Ash.Query.Function.Type{arguments: [expr | _]} -> 24 | expr 25 | 26 | %Ash.Query.Call{name: :type, args: [expr | _]} -> 27 | expr 28 | 29 | _ -> 30 | expr 31 | end 32 | 33 | expr = 34 | if AshSql.Calculation.map_type?( 35 | attribute.type, 36 | attribute.constraints || [] 37 | ) do 38 | expr 39 | else 40 | maybe_cast_atomic_expr( 41 | expr, 42 | attribute, 43 | query.__ash_bindings__.sql_behaviour, 44 | resource 45 | ) 46 | end 47 | 48 | type = 49 | case query.__ash_bindings__.sql_behaviour.storage_type(resource, attribute.name) do 50 | nil -> {attribute.type, attribute.constraints} 51 | storage_type -> storage_type 52 | end 53 | 54 | case AshSql.Expr.dynamic_expr( 55 | query, 56 | expr, 57 | Map.merge(query.__ash_bindings__, %{ 58 | location: :update 59 | }), 60 | false, 61 | type 62 | ) do 63 | {dynamic, acc} -> 64 | new_field = String.to_atom("__new_#{field}") 65 | 66 | dynamic = 67 | if is_map(dynamic) and not is_struct(dynamic) do 68 | Ecto.Query.dynamic(type(^dynamic, :map)) 69 | else 70 | dynamic 71 | end 72 | 73 | {:cont, 74 | {:ok, AshSql.Expr.merge_accumulator(query, acc), dynamics ++ [{new_field, dynamic}]}} 75 | 76 | other -> 77 | {:halt, other} 78 | end 79 | end) 80 | |> case do 81 | {:ok, query, dynamics} -> 82 | query = Ecto.Query.exclude(query, :select) 83 | 84 | pkey_dynamics = 85 | resource 86 | |> Ash.Resource.Info.primary_key() 87 | |> Enum.map(fn key -> 88 | {key, Ecto.Query.dynamic([row], field(row, ^key))} 89 | end) 90 | 91 | dynamics = Map.new(Keyword.merge(dynamics, pkey_dynamics)) 92 | 93 | {:ok, 94 | Ecto.Query.select(query, ^dynamics) 95 | |> Map.update!(:select, fn select -> 96 | %{ 97 | select 98 | | subqueries: Enum.map(select.subqueries || [], &set_subquery_prefix(&1, query)) 99 | } 100 | end)} 101 | 102 | other -> 103 | other 104 | end 105 | end 106 | 107 | def set_subquery_prefix(sub_query, query) do 108 | %{ 109 | sub_query 110 | | query: %{ 111 | sub_query.query 112 | | prefix: 113 | subquery_prefix( 114 | sub_query, 115 | query, 116 | sub_query.query.__ash_bindings__.resource 117 | ) 118 | } 119 | } 120 | end 121 | 122 | defp subquery_prefix(sub_query, base_query, resource) do 123 | if Ash.Resource.Info.multitenancy_strategy(resource) == :context do 124 | sub_query.query.__ash_bindings__.sql_behaviour.schema(resource) || 125 | Map.get(Map.get(base_query, :__ash_bindings__), :tenant) || 126 | base_query.prefix || 127 | sub_query.query.__ash_bindings__.sql_behaviour.repo(resource, :mutate).config()[ 128 | :default_prefix 129 | ] 130 | else 131 | sub_query.query.__ash_bindings__.sql_behaviour.schema(resource) || 132 | sub_query.query.__ash_bindings__.sql_behaviour.repo(resource, :mutate).config()[ 133 | :default_prefix 134 | ] 135 | end 136 | end 137 | 138 | defp maybe_cast_atomic_expr(expr, attribute, sql_behaviour, resource) do 139 | storage_type = sql_behaviour.storage_type(resource, attribute.name) 140 | 141 | cond do 142 | is_list(expr) and typed_struct_array_attr_type?(attribute.type) and 143 | storage_type in [:map, :jsonb, :json] -> 144 | dump_and_encode_map_array(expr, attribute) 145 | 146 | is_list(expr) and embedded_ash_resource?(Enum.at(expr, 0)) and 147 | storage_type in [:map, :jsonb, :json] -> 148 | # Embedded resources with jsonb storage need to be dumped to native format 149 | dump_and_encode_map_array(expr, attribute) 150 | 151 | is_list(expr) and not embedded_ash_resource?(Enum.at(expr, 0)) -> 152 | {:ok, casted} = 153 | Ash.Query.Function.Type.new([expr, attribute.type, attribute.constraints || []]) 154 | 155 | casted 156 | 157 | true -> 158 | expr 159 | end 160 | end 161 | 162 | defp typed_struct_array_attr_type?({:array, attr_type}) do 163 | function_exported?(attr_type, :spark_is, 0) and attr_type.spark_is() == Ash.TypedStruct 164 | end 165 | 166 | defp typed_struct_array_attr_type?(_attr_type), do: false 167 | 168 | defp embedded_ash_resource?(value) do 169 | is_struct(value) and Ash.Resource.Info.resource?(value.__struct__) and 170 | Ash.Resource.Info.embedded?(value.__struct__) 171 | end 172 | 173 | defp dump_and_encode_map_array(expr, %{type: {:array, inner_type}} = attribute) do 174 | dumped_list = 175 | Enum.map(expr, fn item -> 176 | case Ash.Type.dump_to_native(inner_type, item, attribute.constraints[:items] || []) do 177 | {:ok, dumped} -> dumped 178 | :error -> item 179 | end 180 | end) 181 | 182 | {:ok, type_expr} = 183 | Ash.Query.Function.Type.new([ 184 | dumped_list, 185 | AshSql.TypedStructArrayJsonb, 186 | attribute.constraints 187 | ]) 188 | 189 | type_expr 190 | end 191 | 192 | # sobelow_skip ["DOS.StringToAtom"] 193 | def query_with_atomics( 194 | resource, 195 | %{__ash_bindings__: %{atomics_in_binding: binding}} = query, 196 | filter, 197 | atomics, 198 | updating_one_changes, 199 | existing_set 200 | ) do 201 | {:ok, query} = 202 | if is_nil(filter) do 203 | {:ok, query} 204 | else 205 | AshSql.Filter.filter(query, filter, resource) 206 | end 207 | 208 | {query, dynamics} = 209 | atomics 210 | |> Enum.reverse() 211 | |> Enum.reduce({query, []}, fn {field, _expr}, {query, set} -> 212 | mapped_field = String.to_atom("__new_#{field}") 213 | 214 | {query, [{field, Ecto.Query.dynamic([], field(as(^binding), ^mapped_field))} | set]} 215 | end) 216 | 217 | Enum.reduce_while( 218 | dynamics ++ existing_set, 219 | {:ok, query, Map.to_list(updating_one_changes)}, 220 | fn {key, value}, {:ok, query, set} -> 221 | case AshSql.Expr.dynamic_expr(query, value, query.__ash_bindings__) do 222 | {dynamic, acc} -> 223 | {:cont, 224 | {:ok, AshSql.Expr.merge_accumulator(query, acc), Keyword.put(set, key, dynamic)}} 225 | 226 | other -> 227 | {:halt, other} 228 | end 229 | end 230 | ) 231 | |> case do 232 | {:ok, query, []} -> 233 | {:empty, query} 234 | 235 | {:ok, query, set} -> 236 | {:ok, Ecto.Query.update(query, set: ^set)} 237 | 238 | {:error, error} -> 239 | {:error, error} 240 | end 241 | end 242 | 243 | @moduledoc false 244 | def query_with_atomics( 245 | resource, 246 | query, 247 | filter, 248 | atomics, 249 | updating_one_changes, 250 | existing_set 251 | ) do 252 | atomics = type_atomics(query.__ash_bindings__.sql_behaviour, resource, atomics) 253 | 254 | {:ok, query} = 255 | if is_nil(filter) do 256 | {:ok, query} 257 | else 258 | AshSql.Filter.filter(query, filter, resource) 259 | end 260 | 261 | atomics 262 | |> Enum.reduce_while( 263 | {:ok, query, existing_set ++ Map.to_list(updating_one_changes)}, 264 | fn {field, expr}, {:ok, query, set} -> 265 | attribute = Ash.Resource.Info.attribute(resource, field) 266 | 267 | expr = 268 | case expr do 269 | %Ash.Query.Function.Type{arguments: [expr | _]} -> 270 | expr 271 | 272 | %Ash.Query.Call{name: :type, args: [expr | _]} -> 273 | expr 274 | 275 | _ -> 276 | expr 277 | end 278 | 279 | expr = 280 | if AshSql.Calculation.map_type?( 281 | attribute.type, 282 | attribute.constraints || [] 283 | ) do 284 | expr 285 | else 286 | maybe_cast_atomic_expr( 287 | expr, 288 | attribute, 289 | query.__ash_bindings__.sql_behaviour, 290 | resource 291 | ) 292 | end 293 | 294 | type = 295 | case query.__ash_bindings__.sql_behaviour.storage_type(resource, attribute.name) do 296 | nil -> {attribute.type, attribute.constraints} 297 | storage_type -> storage_type 298 | end 299 | 300 | case AshSql.Expr.dynamic_expr( 301 | query, 302 | expr, 303 | Map.merge(query.__ash_bindings__, %{ 304 | location: :update 305 | }), 306 | false, 307 | type 308 | ) do 309 | {dynamic, acc} -> 310 | {:cont, 311 | {:ok, AshSql.Expr.merge_accumulator(query, acc), Keyword.put(set, field, dynamic)}} 312 | 313 | other -> 314 | {:halt, other} 315 | end 316 | end 317 | ) 318 | |> case do 319 | {:ok, query, []} -> 320 | {:empty, query} 321 | 322 | {:ok, query, set} -> 323 | {:ok, Ecto.Query.update(query, set: ^set)} 324 | 325 | {:error, error} -> 326 | {:error, error} 327 | end 328 | end 329 | 330 | defp type_atomics(sql_behaviour, resource, atomics) do 331 | Enum.map(atomics, fn {key, expr} -> 332 | attribute = Ash.Resource.Info.attribute(resource, key) 333 | 334 | expr = 335 | case sql_behaviour.storage_type(resource, attribute.name) do 336 | nil -> 337 | %Ash.Query.Function.Type{arguments: [expr, attribute.type, attribute.constraints]} 338 | 339 | _ -> 340 | expr 341 | end 342 | 343 | {key, expr} 344 | end) 345 | end 346 | end 347 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "ash": {:hex, :ash, "3.7.6", "a0358e8467da4e2a94855542d07d7fca8e74cb6bc89c42af2181b4caa91f8415", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.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, "~> 0.11", [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.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [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", "6003aa4dec5868e6371c3bf2efdb89507c59c05f5dbec13a13b73a92b938a258"}, 3 | "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"}, 4 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 5 | "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [: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", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, 6 | "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [: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", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, 7 | "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, 8 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 9 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 10 | "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, 11 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 12 | "ecto": {:hex, :ecto, "3.13.4", "27834b45d58075d4a414833d9581e8b7bb18a8d9f264a21e42f653d500dbeeb5", [:mix], [{:decimal, "~> 2.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", "5ad7d1505685dfa7aaf86b133d54f5ad6c42df0b4553741a1ff48796736e88b2"}, 13 | "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [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", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, 14 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 15 | "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, 16 | "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, 17 | "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [: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", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, 18 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 19 | "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 20 | "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, 21 | "git_ops": {:hex, :git_ops, "2.9.0", "b74f6040084f523055b720cc7ef718da47f2cbe726a5f30c2871118635cb91c1", [: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", "7fdf84be3490e5692c5dc1f8a1084eed47a221c1063e41938c73312f0bfea259"}, 22 | "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, 23 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 24 | "igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [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", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"}, 25 | "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, 26 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 27 | "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, 28 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 29 | "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"}, 30 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 31 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 32 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [: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", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 33 | "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"}, 34 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 35 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 36 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 37 | "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, 38 | "reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [: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]}, {:libgraph, "~> 0.16", [hex: :libgraph, 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", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"}, 39 | "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [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", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, 40 | "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [: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", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, 41 | "simple_sat": {:hex, :simple_sat, "0.1.4", "39baf72cdca14f93c0b6ce2b6418b72bbb67da98fa9ca4384e2f79bbc299899d", [:mix], [], "hexpm", "3569b68e346a5fd7154b8d14173ff8bcc829f2eb7b088c30c3f42a383443930b"}, 42 | "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, 43 | "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, 44 | "spark": {:hex, :spark, "2.3.12", "55f597df09cd38944c888f00e12f8b1f1fd94b0b4ed76a199e1d1d8251d9220a", [: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", "4f69b30cab6ac72e6f16e0f6b4f815d3ce3915628612f38059dcea4a25b53fe0"}, 45 | "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, 46 | "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, 47 | "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, 48 | "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, 49 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 50 | "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, 51 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 52 | "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, 53 | "ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"}, 54 | } 55 | -------------------------------------------------------------------------------- /lib/sort.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.Sort do 6 | @moduledoc false 7 | require Ecto.Query 8 | 9 | def sort( 10 | query, 11 | sort, 12 | resource, 13 | relationship_path \\ [], 14 | binding \\ 0, 15 | type \\ :window 16 | ) do 17 | sort = 18 | Enum.map(sort, fn 19 | {key, val} when is_atom(key) -> 20 | case Ash.Resource.Info.field(resource, key) do 21 | %Ash.Resource.Calculation{calculation: {module, opts}} = calculation -> 22 | {:ok, calculation} = 23 | Ash.Query.Calculation.new( 24 | calculation.name, 25 | module, 26 | opts, 27 | calculation.type, 28 | calculation.constraints 29 | ) 30 | 31 | calculation = 32 | Ash.Actions.Read.add_calc_context( 33 | calculation, 34 | query.__ash_bindings__.context[:private][:actor], 35 | query.__ash_bindings__.context[:private][:authorize?], 36 | query.__ash_bindings__.context[:private][:tenant], 37 | query.__ash_bindings__.context[:private][:tracer], 38 | query.__ash_bindings__.context[:private][:domain], 39 | query.__ash_bindings__.context[:private][:resource], 40 | parent_stack: query.__ash_bindings__[:parent_resources] || [] 41 | ) 42 | 43 | {calculation, val} 44 | 45 | %Ash.Resource.Aggregate{} = aggregate -> 46 | related = Ash.Resource.Info.related(resource, aggregate.relationship_path) 47 | 48 | read_action = 49 | aggregate.read_action || 50 | Ash.Resource.Info.primary_action!( 51 | related, 52 | :read 53 | ).name 54 | 55 | with %{valid?: true} = aggregate_query <- Ash.Query.for_read(related, read_action), 56 | %{valid?: true} = aggregate_query <- 57 | Ash.Query.build(aggregate_query, 58 | filter: aggregate.filter, 59 | sort: aggregate.sort 60 | ) do 61 | case Ash.Query.Aggregate.new( 62 | resource, 63 | aggregate.name, 64 | aggregate.kind, 65 | path: aggregate.relationship_path, 66 | query: aggregate_query, 67 | field: aggregate.field, 68 | default: aggregate.default, 69 | filterable?: aggregate.filterable?, 70 | type: aggregate.type, 71 | sortable?: aggregate.filterable?, 72 | include_nil?: aggregate.include_nil?, 73 | constraints: aggregate.constraints, 74 | implementation: aggregate.implementation, 75 | uniq?: aggregate.uniq?, 76 | read_action: 77 | aggregate.read_action || 78 | Ash.Resource.Info.primary_action!( 79 | Ash.Resource.Info.related(resource, aggregate.relationship_path), 80 | :read 81 | ).name, 82 | authorize?: aggregate.authorize? 83 | ) do 84 | {:ok, agg} -> 85 | {agg, val} 86 | 87 | {:error, error} -> 88 | raise Ash.Error.to_ash_error(error) 89 | end 90 | else 91 | %{errors: errors} -> raise Ash.Error.to_ash_error(errors) 92 | end 93 | 94 | _ -> 95 | {key, val} 96 | end 97 | 98 | {key, val} -> 99 | {key, val} 100 | end) 101 | 102 | used_aggregates = 103 | Enum.flat_map(sort, fn 104 | {%Ash.Query.Calculation{} = calculation, _} -> 105 | case Ash.Filter.hydrate_refs( 106 | calculation.module.expression(calculation.opts, calculation.context), 107 | %{ 108 | resource: resource, 109 | aggregates: %{}, 110 | parent_stack: query.__ash_bindings__[:parent_resources] || [], 111 | calculations: %{}, 112 | public?: false 113 | } 114 | ) do 115 | {:ok, hydrated} -> 116 | Ash.Filter.used_aggregates(hydrated) 117 | 118 | _ -> 119 | [] 120 | end 121 | 122 | {%Ash.Query.Aggregate{} = aggregate, _} -> 123 | [aggregate] 124 | 125 | {key, _} -> 126 | case Ash.Resource.Info.aggregate(resource, key) do 127 | nil -> 128 | [] 129 | 130 | aggregate -> 131 | [aggregate] 132 | end 133 | 134 | _ -> 135 | [] 136 | end) 137 | 138 | calcs = 139 | Enum.flat_map(sort, fn 140 | {%Ash.Query.Calculation{} = calculation, _} -> 141 | {:ok, expression} = 142 | calculation.opts 143 | |> calculation.module.expression(calculation.context) 144 | |> Ash.Filter.hydrate_refs(%{ 145 | resource: resource, 146 | parent_stack: query.__ash_bindings__[:parent_resources] || [], 147 | public?: false 148 | }) 149 | 150 | [{calculation, Ash.Filter.move_to_relationship_path(expression, relationship_path)}] 151 | 152 | _ -> 153 | [] 154 | end) 155 | 156 | {:ok, query} = 157 | AshSql.Join.join_all_relationships( 158 | query, 159 | %Ash.Filter{ 160 | resource: resource, 161 | expression: Enum.map(calcs, &elem(&1, 1)) 162 | }, 163 | left_only?: true 164 | ) 165 | 166 | case AshSql.Aggregate.add_aggregates(query, used_aggregates, resource, false, 0) do 167 | {:error, error} -> 168 | {:error, error} 169 | 170 | {:ok, query} -> 171 | sort 172 | |> sanitize_sort() 173 | |> Enum.reduce_while({:ok, [], query}, fn 174 | {order, %Ash.Query.Aggregate{} = agg}, {:ok, query_expr, query} -> 175 | type = 176 | if agg.type do 177 | AshSql.Expr.parameterized_type( 178 | query.__ash_bindings__.sql_behaviour, 179 | agg.type, 180 | agg.constraints, 181 | :sort 182 | ) 183 | else 184 | nil 185 | end 186 | 187 | expr = 188 | %Ash.Query.Ref{ 189 | attribute: agg, 190 | resource: resource, 191 | relationship_path: relationship_path 192 | } 193 | 194 | bindings = query.__ash_bindings__ 195 | 196 | {expr, acc} = 197 | AshSql.Expr.dynamic_expr( 198 | query, 199 | expr, 200 | bindings, 201 | false, 202 | type 203 | ) 204 | 205 | {:cont, 206 | {:ok, query_expr ++ [{order, expr}], 207 | AshSql.Bindings.merge_expr_accumulator(query, acc)}} 208 | 209 | {order, %Ash.Query.Calculation{} = calc}, {:ok, query_expr, query} -> 210 | type = 211 | if calc.type do 212 | AshSql.Expr.parameterized_type( 213 | query.__ash_bindings__.sql_behaviour, 214 | calc.type, 215 | calc.constraints, 216 | :sort 217 | ) 218 | else 219 | nil 220 | end 221 | 222 | calc.opts 223 | |> calc.module.expression(calc.context) 224 | |> Ash.Filter.hydrate_refs(%{ 225 | resource: resource, 226 | parent_stack: query.__ash_bindings__[:parent_resources] || [], 227 | public?: false 228 | }) 229 | |> Ash.Filter.move_to_relationship_path(relationship_path) 230 | |> case do 231 | {:ok, expr} -> 232 | bindings = query.__ash_bindings__ 233 | 234 | {expr, acc} = 235 | AshSql.Expr.dynamic_expr( 236 | query, 237 | expr, 238 | bindings, 239 | false, 240 | type 241 | ) 242 | 243 | {:cont, 244 | {:ok, query_expr ++ [{order, expr}], 245 | AshSql.Bindings.merge_expr_accumulator(query, acc)}} 246 | 247 | {:error, error} -> 248 | {:halt, {:error, error}} 249 | end 250 | 251 | {order, sort}, {:ok, query_expr, query} -> 252 | expr = 253 | case find_aggregate_binding( 254 | query.__ash_bindings__.bindings, 255 | relationship_path, 256 | sort 257 | ) do 258 | {:ok, binding} -> 259 | aggregate = 260 | Ash.Resource.Info.aggregate(resource, sort) || 261 | raise "No such aggregate for query aggregate #{inspect(sort)}" 262 | 263 | {:ok, attribute_type} = 264 | if aggregate.field do 265 | related = Ash.Resource.Info.related(resource, aggregate.relationship_path) 266 | 267 | attr = Ash.Resource.Info.attribute(related, aggregate.field) 268 | 269 | if attr && related do 270 | {:ok, 271 | AshSql.Expr.parameterized_type( 272 | query.__ash_bindings__.sql_behaviour, 273 | attr.type, 274 | attr.constraints, 275 | :sort 276 | )} 277 | else 278 | {:ok, nil} 279 | end 280 | else 281 | {:ok, nil} 282 | end 283 | 284 | default_value = 285 | if is_function(aggregate.default) do 286 | aggregate.default.() 287 | else 288 | aggregate.default 289 | end 290 | 291 | default_value = 292 | default_value || Ash.Query.Aggregate.default_value(aggregate.kind) 293 | 294 | if is_nil(default_value) do 295 | Ecto.Query.dynamic(field(as(^binding), ^sort)) 296 | else 297 | if attribute_type do 298 | typed_default = 299 | query.__ash_bindings__.sql_behaviour.type_expr( 300 | default_value, 301 | type 302 | ) 303 | 304 | Ecto.Query.dynamic( 305 | coalesce( 306 | field(as(^binding), ^sort), 307 | ^typed_default 308 | ) 309 | ) 310 | else 311 | Ecto.Query.dynamic(coalesce(field(as(^binding), ^sort), ^default_value)) 312 | end 313 | end 314 | 315 | :error -> 316 | aggregate = Ash.Resource.Info.aggregate(resource, sort) 317 | 318 | {binding, sort} = 319 | if aggregate && 320 | AshSql.Aggregate.optimizable_first_aggregate?(resource, aggregate, query) do 321 | {AshSql.Join.get_binding( 322 | resource, 323 | aggregate.relationship_path, 324 | query, 325 | [ 326 | :left, 327 | :inner 328 | ] 329 | ), aggregate.field} 330 | else 331 | {binding, sort} 332 | end 333 | 334 | Ecto.Query.dynamic(field(as(^binding), ^sort)) 335 | end 336 | 337 | {:cont, {:ok, query_expr ++ [{order, expr}], query}} 338 | end) 339 | |> case do 340 | {:ok, [], query} -> 341 | if type == :return do 342 | {:ok, [], query} 343 | else 344 | {:ok, query} 345 | end 346 | 347 | {:ok, sort_exprs, query} -> 348 | case type do 349 | :return -> 350 | {:ok, order_to_fragments(sort_exprs), query} 351 | 352 | :window -> 353 | new_query = Ecto.Query.order_by(query, ^sort_exprs) 354 | 355 | sort_expr = List.last(new_query.order_bys) 356 | 357 | new_query = 358 | new_query 359 | |> Map.update!(:windows, fn windows -> 360 | order_by_expr = %{sort_expr | expr: [order_by: sort_expr.expr]} 361 | Keyword.put(windows, :order, order_by_expr) 362 | end) 363 | |> Map.update!(:__ash_bindings__, &Map.put(&1, :__order__?, true)) 364 | 365 | {:ok, new_query} 366 | 367 | :direct -> 368 | {:ok, query |> Ecto.Query.order_by(^sort_exprs) |> set_sort_applied()} 369 | end 370 | 371 | {:error, error} -> 372 | {:error, error} 373 | end 374 | end 375 | end 376 | 377 | def find_aggregate_binding(bindings, relationship_path, sort) do 378 | Enum.find_value( 379 | bindings, 380 | :error, 381 | fn 382 | {key, %{type: :aggregate, path: ^relationship_path, aggregates: aggregates}} -> 383 | if Enum.any?(aggregates, &(&1.name == sort)) do 384 | {:ok, key} 385 | end 386 | 387 | _ -> 388 | nil 389 | end 390 | ) 391 | end 392 | 393 | def order_to_fragments([]), do: [] 394 | 395 | def order_to_fragments([last]) do 396 | [do_order_to_fragments(last, false)] 397 | end 398 | 399 | def order_to_fragments([first | rest]) do 400 | [do_order_to_fragments(first, true) | order_to_fragments(rest)] 401 | end 402 | 403 | def do_order_to_fragments({order, sort}, comma?) do 404 | case {order, comma?} do 405 | {:asc, false} -> 406 | Ecto.Query.dynamic([row], fragment("? ASC", ^sort)) 407 | 408 | {:desc, false} -> 409 | Ecto.Query.dynamic([row], fragment("? DESC", ^sort)) 410 | 411 | {:asc_nulls_last, false} -> 412 | Ecto.Query.dynamic([row], fragment("? ASC NULLS LAST", ^sort)) 413 | 414 | {:asc_nulls_first, false} -> 415 | Ecto.Query.dynamic([row], fragment("? ASC NULLS FIRST", ^sort)) 416 | 417 | {:desc_nulls_first, false} -> 418 | Ecto.Query.dynamic([row], fragment("? DESC NULLS FIRST", ^sort)) 419 | 420 | {:desc_nulls_last, false} -> 421 | Ecto.Query.dynamic([row], fragment("? DESC NULLS LAST", ^sort)) 422 | "DESC NULLS LAST" 423 | 424 | {:asc, true} -> 425 | Ecto.Query.dynamic([row], fragment("? ASC, ", ^sort)) 426 | 427 | {:desc, true} -> 428 | Ecto.Query.dynamic([row], fragment("? DESC, ", ^sort)) 429 | 430 | {:asc_nulls_last, true} -> 431 | Ecto.Query.dynamic([row], fragment("? ASC NULLS LAST, ", ^sort)) 432 | 433 | {:asc_nulls_first, true} -> 434 | Ecto.Query.dynamic([row], fragment("? ASC NULLS FIRST, ", ^sort)) 435 | 436 | {:desc_nulls_first, true} -> 437 | Ecto.Query.dynamic([row], fragment("? DESC NULLS FIRST, ", ^sort)) 438 | 439 | {:desc_nulls_last, true} -> 440 | Ecto.Query.dynamic([row], fragment("? DESC NULLS LAST, ", ^sort)) 441 | "DESC NULLS LAST" 442 | end 443 | end 444 | 445 | def order_to_sql_order(dir) do 446 | case dir do 447 | :asc -> nil 448 | :asc_nils_last -> " ASC NULLS LAST" 449 | :asc_nils_first -> " ASC NULLS FIRST" 450 | :desc -> " DESC" 451 | :desc_nils_last -> " DESC NULLS LAST" 452 | :desc_nils_first -> " DESC NULLS FIRST" 453 | end 454 | end 455 | 456 | def apply_sort(query, sort, resource, type \\ :window) 457 | 458 | def apply_sort(query, sort, _resource, _) when sort in [nil, []] do 459 | {:ok, query |> set_sort_applied()} 460 | end 461 | 462 | def apply_sort(query, sort, resource, type) do 463 | AshSql.Sort.sort(query, sort, resource, [], query.__ash_bindings__.root_binding, type) 464 | end 465 | 466 | defp set_sort_applied(query) do 467 | Map.update!(query, :__ash_bindings__, &Map.put(&1, :sort_applied?, true)) 468 | end 469 | 470 | def sanitize_sort(sort) do 471 | sort 472 | |> List.wrap() 473 | |> Enum.map(fn 474 | {sort, {order, context}} -> 475 | {ash_to_ecto_order(order), {sort, context}} 476 | 477 | {sort, order} -> 478 | {ash_to_ecto_order(order), sort} 479 | 480 | sort -> 481 | sort 482 | end) 483 | end 484 | 485 | defp ash_to_ecto_order(:asc_nils_last), do: :asc_nulls_last 486 | defp ash_to_ecto_order(:asc_nils_first), do: :asc_nulls_first 487 | defp ash_to_ecto_order(:desc_nils_last), do: :desc_nulls_last 488 | defp ash_to_ecto_order(:desc_nils_first), do: :desc_nulls_first 489 | defp ash_to_ecto_order(other), do: other 490 | end 491 | -------------------------------------------------------------------------------- /lib/query.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.Query do 6 | @moduledoc false 7 | import Ecto.Query, only: [subquery: 1, from: 2] 8 | require Ecto.Query 9 | require Ash.Expr 10 | 11 | def resource_to_query(resource, implementation, domain \\ nil) do 12 | from(row in {implementation.table(resource) || "", resource}, []) 13 | |> Map.put(:__ash_domain__, domain) 14 | end 15 | 16 | def combination_acc(query), do: query.__ash_bindings__.current 17 | 18 | def combination_of( 19 | [{:base, first} | combination_of], 20 | _resource, 21 | _implementation, 22 | _domain \\ nil 23 | ) do 24 | Enum.reduce(combination_of, subquery(first), fn {type, combination_of}, query -> 25 | case type do 26 | :union -> 27 | Ecto.Query.union(query, ^combination_of) 28 | 29 | :union_all -> 30 | Ecto.Query.union_all(query, ^combination_of) 31 | 32 | :intersect -> 33 | Ecto.Query.intersect(query, ^combination_of) 34 | 35 | :except -> 36 | Ecto.Query.except(query, ^combination_of) 37 | end 38 | end) 39 | |> then(&{:ok, &1}) 40 | end 41 | 42 | def set_context(resource, data_layer_query, sql_behaviour, context) do 43 | data_layer_query = 44 | if context[:data_layer][:combination_of_queries?] do 45 | from(row in subquery(data_layer_query), []) 46 | else 47 | data_layer_query 48 | end 49 | 50 | default_start_bindings = 51 | if context[:data_layer][:lateral_join_source] do 52 | 500 53 | else 54 | case context[:data_layer][:previous_combination] do 55 | %{__ash_bindings__: %{current: current}} -> 56 | current 57 | 58 | current when is_integer(current) -> 59 | current 60 | 61 | _ -> 62 | 0 63 | end 64 | end 65 | 66 | start_bindings = context[:data_layer][:start_bindings_at] || default_start_bindings 67 | 68 | context = 69 | context 70 | |> Map.put_new(:data_layer, %{}) 71 | |> Map.update!(:data_layer, &Map.put(&1, :start_bindings_at, start_bindings)) 72 | 73 | data_layer_query = from(row in data_layer_query, as: ^start_bindings) 74 | 75 | data_layer_query = 76 | if context[:data_layer][:table] do 77 | %{ 78 | data_layer_query 79 | | from: %{data_layer_query.from | source: {context[:data_layer][:table], resource}} 80 | } 81 | else 82 | data_layer_query 83 | end 84 | 85 | data_layer_query = 86 | if context[:data_layer][:schema] do 87 | Ecto.Query.put_query_prefix(data_layer_query, to_string(context[:data_layer][:schema])) 88 | else 89 | data_layer_query 90 | end 91 | 92 | data_layer_query = 93 | data_layer_query 94 | |> AshSql.Bindings.default_bindings(resource, sql_behaviour, context) 95 | |> AshSql.Bindings.add_parent_bindings(context) 96 | 97 | data_layer_query = 98 | case context[:data_layer][:lateral_join_source] do 99 | {data, path} -> 100 | lateral_join_source_query = path |> List.first() |> elem(0) 101 | 102 | lateral_join_source_query.resource 103 | |> Ash.Query.set_context(%{ 104 | :data_layer => 105 | Map.put( 106 | lateral_join_source_query.context[:data_layer] || %{}, 107 | :no_inner_join?, 108 | true 109 | ) 110 | |> Map.delete(:lateral_join_source) 111 | }) 112 | |> Ash.Query.set_tenant(lateral_join_source_query.tenant) 113 | |> set_lateral_join_prefix(data_layer_query) 114 | |> filter_for_records(data) 115 | |> case do 116 | %{valid?: true} = query -> 117 | relationship = path |> List.first() |> elem(3) 118 | 119 | {:ok, expr} = 120 | Ash.Filter.hydrate_refs(relationship.filter, %{ 121 | resource: relationship.destination, 122 | parent_stack: [relationship.source] 123 | }) 124 | 125 | parent_expr = AshSql.Join.parent_expr(expr) 126 | 127 | used_aggregates = 128 | Ash.Filter.used_aggregates(parent_expr, []) 129 | 130 | with {:ok, query} <- Ash.Query.data_layer_query(query) do 131 | AshSql.Aggregate.add_aggregates( 132 | query, 133 | used_aggregates, 134 | relationship.source, 135 | false, 136 | query.__ash_bindings__.root_binding 137 | ) 138 | end 139 | 140 | query -> 141 | {:error, query} 142 | end 143 | |> case do 144 | {:ok, lateral_join_source_query} -> 145 | lateral_join_source_query = 146 | if Enum.count(path) == 2 do 147 | Map.update!(lateral_join_source_query, :__ash_bindings__, fn bindings -> 148 | bindings 149 | |> Map.put(:lateral_join_bindings, [start_bindings + 1]) 150 | |> Map.update!(:bindings, fn bindings -> 151 | Map.put( 152 | bindings, 153 | start_bindings + 1, 154 | %{ 155 | source: path |> Enum.at(1) |> elem(3) |> Map.get(:source), 156 | path: [path |> Enum.at(1) |> elem(3) |> Map.get(:name)], 157 | type: :inner 158 | } 159 | ) 160 | end) 161 | end) 162 | else 163 | lateral_join_source_query 164 | end 165 | 166 | {:ok, 167 | Map.update!(data_layer_query, :__ash_bindings__, fn bindings -> 168 | Map.put( 169 | bindings, 170 | :lateral_join_source_query, 171 | lateral_join_source_query 172 | ) 173 | end)} 174 | 175 | {:error, error} -> 176 | {:error, error} 177 | end 178 | 179 | _ -> 180 | {:ok, data_layer_query} 181 | end 182 | 183 | case data_layer_query do 184 | {:error, error} -> 185 | {:error, error} 186 | 187 | {:ok, data_layer_query} -> 188 | {domain, data_layer_query} = Map.pop(data_layer_query, :__ash_domain__) 189 | 190 | case context[:data_layer][:lateral_join_source] do 191 | {_, _} -> 192 | data_layer_query = 193 | data_layer_query 194 | |> Map.update!(:__ash_bindings__, &Map.put(&1, :lateral_join?, true)) 195 | |> Map.update!(:__ash_bindings__, &Map.put(&1, :domain, domain)) 196 | 197 | {:ok, data_layer_query} 198 | 199 | _ -> 200 | ash_bindings = 201 | data_layer_query.__ash_bindings__ 202 | |> Map.put(:lateral_join?, false) 203 | |> Map.put(:domain, domain) 204 | 205 | {:ok, %{data_layer_query | __ash_bindings__: ash_bindings}} 206 | end 207 | end 208 | end 209 | 210 | defp filter_for_records(query, records) do 211 | keys = 212 | case Ash.Resource.Info.primary_key(query.resource) do 213 | [] -> 214 | case Ash.Resource.Info.identities(query.resource) do 215 | [%{keys: keys} | _] -> keys 216 | _ -> [] 217 | end 218 | 219 | pkey -> 220 | pkey 221 | end 222 | 223 | expr = 224 | case keys do 225 | [] -> 226 | raise "Cannot use lateral joins with a resource that has no primary key and no identities" 227 | 228 | [key] -> 229 | Ash.Expr.expr(^Ash.Expr.ref(key) in ^Enum.map(records, &Map.get(&1, key))) 230 | 231 | keys -> 232 | Enum.reduce(records, Ash.Expr.expr(false), fn record, filter_expr -> 233 | all_keys_match_expr = 234 | Enum.reduce(keys, Ash.Expr.expr(true), fn key, key_expr -> 235 | Ash.Expr.expr(^key_expr and ^Ash.Expr.ref(key) == ^Map.get(record, key)) 236 | end) 237 | 238 | Ash.Expr.expr(^filter_expr or ^all_keys_match_expr) 239 | end) 240 | end 241 | 242 | Ash.Query.do_filter(query, expr) 243 | end 244 | 245 | def return_query(%{__ash_bindings__: %{lateral_join?: true}} = query, resource) do 246 | query = 247 | AshSql.Bindings.default_bindings(query, resource, query.__ash_bindings__.sql_behaviour) 248 | 249 | if query.__ash_bindings__[:sort_applied?] do 250 | {:ok, query} 251 | else 252 | AshSql.Sort.apply_sort( 253 | query, 254 | query.__ash_bindings__[:sort], 255 | query.__ash_bindings__.resource 256 | ) 257 | end 258 | |> case do 259 | {:ok, query} -> 260 | load_aggs = query.__ash_bindings__[:load_aggregates] || [] 261 | 262 | if Enum.empty?(load_aggs) do 263 | {:ok, query} 264 | else 265 | AshSql.Aggregate.add_aggregates( 266 | query, 267 | load_aggs, 268 | resource, 269 | true, 270 | query.__ash_bindings__.root_binding 271 | ) 272 | end 273 | 274 | {:error, error} -> 275 | {:error, error} 276 | end 277 | end 278 | 279 | def return_query(query, resource) do 280 | query = 281 | AshSql.Bindings.default_bindings(query, resource, query.__ash_bindings__.sql_behaviour) 282 | 283 | with_sort_applied = 284 | if query.__ash_bindings__[:sort_applied?] do 285 | {:ok, query} 286 | else 287 | AshSql.Sort.apply_sort(query, query.__ash_bindings__[:sort], resource) 288 | end 289 | 290 | case with_sort_applied do 291 | {:error, error} -> 292 | {:error, error} 293 | 294 | {:ok, query} -> 295 | query = 296 | if query.__ash_bindings__[:__order__?] && query.windows[:order] do 297 | if query.distinct do 298 | {calculations_require_rewrite, aggregates_require_rewrite, query} = 299 | rewrite_nested_selects(query) 300 | 301 | parent_ref_attrs = 302 | query.__ash_bindings__[:load_aggregates] 303 | |> List.wrap() 304 | |> extract_aggregate_parent_ref_attributes(resource, query) 305 | 306 | query_with_parent_refs = 307 | Enum.reduce(parent_ref_attrs, query, fn attr, query -> 308 | root_binding = query.__ash_bindings__.root_binding 309 | from(row in query, select_merge: %{^attr => field(as(^root_binding), ^attr)}) 310 | end) 311 | 312 | query_with_order = 313 | from(row in query_with_parent_refs, 314 | select_merge: %{__order__: over(row_number(), :order)} 315 | ) 316 | 317 | query_without_limit_and_offset = 318 | query_with_order 319 | |> Ecto.Query.exclude(:limit) 320 | |> Ecto.Query.exclude(:offset) 321 | 322 | from(row in subquery(query_without_limit_and_offset), 323 | as: ^0, 324 | select: row, 325 | order_by: row.__order__ 326 | ) 327 | |> Map.put(:limit, query.limit) 328 | |> Map.put(:offset, query.offset) 329 | |> AshSql.Bindings.default_bindings( 330 | resource, 331 | query.__ash_bindings__.sql_behaviour, 332 | query.__ash_bindings__.context 333 | ) 334 | |> Map.update!(:__ash_bindings__, fn bindings -> 335 | Map.merge( 336 | bindings, 337 | %{ 338 | calculations_require_rewrite: calculations_require_rewrite, 339 | aggregates_require_rewrite: aggregates_require_rewrite, 340 | select: query.__ash_bindings__[:select], 341 | select_calculations: query.__ash_bindings__[:select_calculations], 342 | load_aggregates: query.__ash_bindings__[:load_aggregates] 343 | }, 344 | fn _, v1, v2 -> Map.merge(v1, v2) end 345 | ) 346 | end) 347 | else 348 | order_by = %{query.windows[:order] | expr: query.windows[:order].expr[:order_by]} 349 | 350 | %{ 351 | query 352 | | windows: Keyword.delete(query.windows, :order), 353 | order_bys: [order_by] 354 | } 355 | end 356 | else 357 | %{query | windows: Keyword.delete(query.windows, :order)} 358 | end 359 | 360 | combination_fieldset = 361 | query.__ash_bindings__.context[:data_layer][:combination_fieldset] 362 | 363 | query = 364 | if combination_fieldset do 365 | fields = 366 | resource 367 | |> Ash.Resource.Info.fields([:attributes, :calculations, :aggregates]) 368 | |> Enum.map(& &1.name) 369 | 370 | to_add_to_calcs = combination_fieldset -- fields 371 | 372 | add_combination_calcs(query, to_add_to_calcs) 373 | else 374 | query 375 | end 376 | 377 | {:ok, query} 378 | end 379 | |> case do 380 | {:ok, query} -> 381 | load_aggs = query.__ash_bindings__[:load_aggregates] || [] 382 | 383 | if Enum.empty?(load_aggs) do 384 | {:ok, query} 385 | else 386 | AshSql.Aggregate.add_aggregates( 387 | query, 388 | load_aggs, 389 | resource, 390 | true, 391 | query.__ash_bindings__.root_binding 392 | ) 393 | end 394 | 395 | {:error, error} -> 396 | {:error, error} 397 | end 398 | end 399 | 400 | defp set_lateral_join_prefix(ash_query, query) do 401 | if Ash.Resource.Info.multitenancy_strategy(ash_query.resource) == :context do 402 | Ash.Query.set_tenant(ash_query, query.prefix) 403 | else 404 | ash_query 405 | end 406 | end 407 | 408 | defp add_combination_calcs(query, to_add_to_calcs) do 409 | case query.select do 410 | %Ecto.Query.SelectExpr{ 411 | expr: 412 | {:merge, merge_meta, 413 | [ 414 | merge_base, 415 | {:%{}, map_meta, current_merging} 416 | ]} 417 | } = select -> 418 | if Keyword.has_key?(current_merging, :calculations) do 419 | %{ 420 | query 421 | | select: %{ 422 | select 423 | | expr: 424 | {:merge, merge_meta, 425 | [ 426 | merge_base, 427 | {:%{}, map_meta, 428 | add_combinations_to_calc_map( 429 | current_merging, 430 | to_add_to_calcs, 431 | query.__ash_bindings__.root_binding 432 | )} 433 | ]} 434 | } 435 | } 436 | else 437 | simple_combination_calcs(query, to_add_to_calcs) 438 | end 439 | 440 | %Ecto.Query.SelectExpr{expr: {:%{}, map_meta, fields}} = select -> 441 | if Keyword.has_key?(fields, :calculations) do 442 | %{ 443 | query 444 | | select: %{ 445 | select 446 | | expr: 447 | {:%{}, map_meta, 448 | add_combinations_to_calc_map( 449 | fields, 450 | to_add_to_calcs, 451 | query.__ash_bindings__.root_binding 452 | )} 453 | } 454 | } 455 | else 456 | simple_combination_calcs(query, to_add_to_calcs) 457 | end 458 | 459 | _ -> 460 | simple_combination_calcs(query, to_add_to_calcs) 461 | end 462 | end 463 | 464 | defp add_combinations_to_calc_map(fields, to_add_to_calcs, root_binding) do 465 | Keyword.update!(fields, :calculations, fn {:%{}, map_meta, calcs} -> 466 | merge = 467 | Keyword.new(to_add_to_calcs, fn name -> 468 | {name, {{:., [], [{:as, [], [root_binding]}, name]}, [], []}} 469 | end) 470 | 471 | {:%{}, map_meta, Keyword.merge(calcs, merge)} 472 | end) 473 | end 474 | 475 | defp simple_combination_calcs(query, to_add_to_calcs) do 476 | dynamics = 477 | Map.new(to_add_to_calcs, fn name -> 478 | {name, Ecto.Query.dynamic([row], field(row, ^name))} 479 | end) 480 | 481 | Ecto.Query.select_merge(query, ^%{calculations: dynamics}) 482 | end 483 | 484 | # sobelow_skip ["DOS.StringToAtom"] 485 | def rewrite_nested_selects(query) do 486 | case query.select do 487 | %Ecto.Query.SelectExpr{ 488 | expr: 489 | {:merge, [], 490 | [ 491 | merge_base, 492 | {:%{}, [], current_merging} 493 | ]} 494 | } = select -> 495 | # as we flatten these, they must all remain in the same relative order 496 | # I'm actually not sure why this is required by ecto, but it is :) 497 | merging = 498 | Enum.flat_map(current_merging, fn 499 | {type, {:%{}, _, type_exprs}} when type in [:calculations, :aggregates] -> 500 | Enum.map(type_exprs, fn {name, expr} -> 501 | {String.to_atom("__#{type}__#{name}"), expr} 502 | end) 503 | 504 | {type, other} -> 505 | [{type, other}] 506 | end) 507 | 508 | aggregate_merges = 509 | current_merging 510 | |> Keyword.get(:aggregates, {:%{}, [], []}) 511 | |> elem(2) 512 | |> Map.new(fn {name, _expr} -> 513 | {String.to_existing_atom("__aggregates__#{name}"), name} 514 | end) 515 | 516 | calculation_merges = 517 | current_merging 518 | |> Keyword.get(:calculations, {:%{}, [], []}) 519 | |> elem(2) 520 | |> Map.new(fn {name, _expr} -> 521 | {String.to_existing_atom("__calculations__#{name}"), name} 522 | end) 523 | 524 | new_query = 525 | %{ 526 | query 527 | | select: %{select | expr: {:merge, [], [merge_base, {:%{}, [], merging}]}} 528 | } 529 | |> Map.update!(:__ash_bindings__, fn bindings -> 530 | Map.update(bindings, :select_calculations, [], &(&1 -- [:calculations])) 531 | end) 532 | 533 | {calculation_merges, aggregate_merges, new_query} 534 | 535 | %Ecto.Query.SelectExpr{expr: {:%{}, map_meta, current_merging}} = select -> 536 | merging = 537 | Enum.flat_map(current_merging, fn 538 | {type, {:%{}, _, type_exprs}} when type in [:calculations, :aggregates] -> 539 | Enum.map(type_exprs, fn {name, expr} -> 540 | {String.to_atom("__#{type}__#{name}"), expr} 541 | end) 542 | 543 | {type, other} -> 544 | [{type, other}] 545 | end) 546 | 547 | aggregate_merges = 548 | current_merging 549 | |> Keyword.get(:aggregates, {:%{}, [], []}) 550 | |> elem(2) 551 | |> Map.new(fn {name, _expr} -> 552 | {String.to_existing_atom("__aggregates__#{name}"), name} 553 | end) 554 | 555 | calculation_merges = 556 | current_merging 557 | |> Keyword.get(:calculations, {:%{}, [], []}) 558 | |> elem(2) 559 | |> Map.new(fn {name, _expr} -> 560 | {String.to_existing_atom("__calculations__#{name}"), name} 561 | end) 562 | 563 | new_query = %{ 564 | query 565 | | select: %{select | expr: {:%{}, map_meta, merging}} 566 | } 567 | 568 | {calculation_merges, aggregate_merges, new_query} 569 | 570 | _ -> 571 | {%{}, %{}, query} 572 | end 573 | end 574 | 575 | def remap_mapped_fields( 576 | results, 577 | query, 578 | calculations_require_rewrite \\ %{}, 579 | aggregates_require_rewrite \\ %{} 580 | ) do 581 | calculation_names = 582 | query.__ash_bindings__.calculation_names 583 | 584 | aggregate_names = query.__ash_bindings__.aggregate_names 585 | 586 | calculations_require_rewrite = 587 | Map.merge( 588 | query.__ash_bindings__[:calculations_require_rewrite] || %{}, 589 | calculations_require_rewrite 590 | ) 591 | 592 | aggregates_require_rewrite = 593 | Map.merge( 594 | query.__ash_bindings__[:aggregates_require_rewrite] || %{}, 595 | aggregates_require_rewrite 596 | ) 597 | 598 | if Enum.empty?(calculation_names) and Enum.empty?(aggregate_names) and 599 | Enum.empty?(calculations_require_rewrite) and Enum.empty?(aggregates_require_rewrite) do 600 | results 601 | else 602 | Enum.map(results, fn result -> 603 | result 604 | |> remap_to_nested(:calculations, calculations_require_rewrite) 605 | |> remap_to_nested(:aggregates, aggregates_require_rewrite) 606 | |> remap(:calculations, calculation_names) 607 | |> remap(:aggregates, aggregate_names) 608 | end) 609 | end 610 | end 611 | 612 | defp remap_to_nested(record, _subfield, mapping) when mapping == %{} do 613 | record 614 | end 615 | 616 | defp remap_to_nested(record, subfield, mapping) do 617 | Map.update!(record, subfield, fn subfield_values -> 618 | Enum.reduce(mapping, subfield_values, fn {source, dest}, subfield_values -> 619 | subfield_values 620 | |> Map.put(dest, Map.get(record, source)) 621 | |> Map.delete(source) 622 | end) 623 | end) 624 | end 625 | 626 | defp remap(record, _subfield, mapping) when mapping == %{} do 627 | record 628 | end 629 | 630 | defp remap(record, subfield, mapping) do 631 | Map.update!(record, subfield, fn subfield_values -> 632 | Enum.reduce(mapping, subfield_values, fn {dest, source}, subfield_values -> 633 | subfield_values 634 | |> Map.put(dest, Map.get(subfield_values, source)) 635 | |> Map.delete(source) 636 | end) 637 | end) 638 | end 639 | 640 | defp extract_aggregate_parent_ref_attributes(aggregates, resource, query) do 641 | aggregates 642 | |> Enum.flat_map(fn aggregate -> 643 | case aggregate.relationship_path do 644 | [first_rel_name | _] -> 645 | relationship = Ash.Resource.Info.relationship(resource, first_rel_name) 646 | 647 | rel_fields = 648 | if !Map.get(relationship, :no_attributes?) && Map.get(relationship, :source_attribute) do 649 | [relationship.source_attribute] 650 | else 651 | [] 652 | end 653 | 654 | if relationship && relationship.filter do 655 | extract_parent_attrs_from_filter(relationship.filter, query) 656 | else 657 | [] 658 | end 659 | |> Enum.concat(rel_fields) 660 | 661 | _ -> 662 | [] 663 | end 664 | end) 665 | |> Enum.uniq() 666 | end 667 | 668 | defp extract_parent_attrs_from_filter(filter, query) do 669 | filter 670 | |> Ash.Actions.Read.add_calc_context_to_filter( 671 | query.__ash_bindings__[:context][:private][:actor], 672 | query.__ash_bindings__[:context][:private][:authorize?], 673 | query.__ash_bindings__[:context][:private][:tenant], 674 | query.__ash_bindings__[:context][:private][:tracer], 675 | query.__ash_bindings__[:domain], 676 | query.__ash_bindings__.resource, 677 | parent_stack: query.__ash_bindings__[:parent_resources] || [] 678 | ) 679 | |> Ash.Filter.flat_map(fn 680 | %Ash.Query.Parent{expr: expr} -> 681 | expr 682 | |> Ash.Filter.list_refs() 683 | |> Enum.filter(&Enum.empty?(&1.relationship_path)) 684 | |> Enum.map(fn ref -> 685 | case ref.attribute do 686 | %{name: name} -> name 687 | name when is_atom(name) -> name 688 | _ -> nil 689 | end 690 | end) 691 | |> Enum.reject(&is_nil/1) 692 | 693 | _other -> 694 | [] 695 | end) 696 | end 697 | end 698 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Change Log 8 | 9 | All notable changes to this project will be documented in this file. 10 | See [Conventional Commits](Https://conventionalcommits.org) for commit guidelines. 11 | 12 | 13 | 14 | ## [v0.3.15](https://github.com/ash-project/ash_sql/compare/v0.3.14...v0.3.15) (2025-11-28) 15 | 16 | 17 | 18 | 19 | ### Bug Fixes: 20 | 21 | * ensure that filtered aggregates are selected in queries by [@zachdaniel](https://github.com/zachdaniel) 22 | 23 | ## [v0.3.14](https://github.com/ash-project/ash_sql/compare/v0.3.13...v0.3.14) (2025-11-23) 24 | 25 | 26 | 27 | 28 | ### Bug Fixes: 29 | 30 | * ensure calculations are selected on aggregate subqueries by [@zachdaniel](https://github.com/zachdaniel) 31 | 32 | * respect read action sort if relationship does not specify one (#194) by [@barnabasJ](https://github.com/barnabasJ) [(#194)](https://github.com/ash-project/ash_sql/pull/194) 33 | 34 | * only merge already-computed aggregates when select? is true (#193) by Alan Heywood [(#193)](https://github.com/ash-project/ash_sql/pull/193) 35 | 36 | * extract_fields_from_expr returns [] instead of all_attribute_names when a query selects all fields ({:&, [], [ix]}) but has no take clause (#192) by Daniel Gollings [(#192)](https://github.com/ash-project/ash_sql/pull/192) 37 | 38 | ## [v0.3.13](https://github.com/ash-project/ash_sql/compare/v0.3.12...v0.3.13) (2025-11-16) 39 | 40 | 41 | 42 | 43 | ### Bug Fixes: 44 | 45 | * correctly extract fields when `take` is present in aggregate by [@zachdaniel](https://github.com/zachdaniel) 46 | 47 | ## [v0.3.12](https://github.com/ash-project/ash_sql/compare/v0.3.11...v0.3.12) (2025-11-09) 48 | 49 | 50 | 51 | 52 | ### Bug Fixes: 53 | 54 | * preserve selected fields when wrapping in subquery for aggregates by [@zachdaniel](https://github.com/zachdaniel) 55 | 56 | ## [v0.3.11](https://github.com/ash-project/ash_sql/compare/v0.3.10...v0.3.11) (2025-11-05) 57 | 58 | 59 | 60 | 61 | ### Bug Fixes: 62 | 63 | * properly type-cast NULL values in dynamic SQL expressions (#185) by [@Torkan](https://github.com/Torkan) 64 | 65 | ## [v0.3.10](https://github.com/ash-project/ash_sql/compare/v0.3.9...v0.3.10) (2025-10-29) 66 | 67 | 68 | 69 | 70 | ### Bug Fixes: 71 | 72 | * properly type-cast NULL values in dynamic SQL expressions (#185) by [@Torkan](https://github.com/Torkan) [(#185)](https://github.com/ash-project/ash_sql/pull/185) 73 | 74 | ## [v0.3.9](https://github.com/ash-project/ash_sql/compare/v0.3.8...v0.3.9) (2025-10-19) 75 | 76 | 77 | 78 | 79 | ### Bug Fixes: 80 | 81 | * more handling of storage types for typed struct arrays in AshSql by [@zachdaniel](https://github.com/zachdaniel) 82 | 83 | ## [v0.3.8](https://github.com/ash-project/ash_sql/compare/v0.3.7...v0.3.8) (2025-10-19) 84 | 85 | 86 | 87 | 88 | ### Bug Fixes: 89 | 90 | * handle typed struct arrays with storage type :jsonb correctly (#183) by [@Torkan](https://github.com/Torkan) [(#183)](https://github.com/ash-project/ash_sql/pull/183) 91 | 92 | * properly handle composition of nested calculation exists by [@zachdaniel](https://github.com/zachdaniel) 93 | 94 | * ensure aggregate default values are always applied by [@zachdaniel](https://github.com/zachdaniel) 95 | 96 | ## [v0.3.7](https://github.com/ash-project/ash_sql/compare/v0.3.6...v0.3.7) (2025-10-15) 97 | 98 | 99 | 100 | 101 | ### Improvements: 102 | 103 | * update Ash to 3.7 and fix deprecated calls by [@zachdaniel](https://github.com/zachdaniel) 104 | 105 | ## [v0.3.6](https://github.com/ash-project/ash_sql/compare/v0.3.5...v0.3.6) (2025-10-15) 106 | 107 | 108 | 109 | 110 | ### Improvements: 111 | 112 | * support combination_acc/1 function to get current combination accumulator by [@zachdaniel](https://github.com/zachdaniel) 113 | 114 | ## [v0.3.5](https://github.com/ash-project/ash_sql/compare/v0.3.4...v0.3.5) (2025-10-15) 115 | 116 | 117 | 118 | 119 | ### Bug Fixes: 120 | 121 | * ensure aggregates are unique by name before adding by [@zachdaniel](https://github.com/zachdaniel) 122 | 123 | ## [v0.3.4](https://github.com/ash-project/ash_sql/compare/v0.3.3...v0.3.4) (2025-10-14) 124 | 125 | 126 | 127 | 128 | ### Bug Fixes: 129 | 130 | * properly avoid adding already computed aggregates by [@zachdaniel](https://github.com/zachdaniel) 131 | 132 | ## [v0.3.3](https://github.com/ash-project/ash_sql/compare/v0.3.2...v0.3.3) (2025-10-14) 133 | 134 | 135 | 136 | 137 | ### Improvements: 138 | 139 | * support massive aggregate optimization by [@zachdaniel](https://github.com/zachdaniel) 140 | 141 | ## [v0.3.2](https://github.com/ash-project/ash_sql/compare/v0.3.1...v0.3.2) (2025-10-10) 142 | 143 | 144 | 145 | 146 | ### Bug Fixes: 147 | 148 | * only do untyped expressions for array get_path types by [@zachdaniel](https://github.com/zachdaniel) 149 | 150 | ## [v0.3.1](https://github.com/ash-project/ash_sql/compare/v0.3.0...v0.3.1) (2025-10-10) 151 | 152 | 153 | 154 | 155 | ### Bug Fixes: 156 | 157 | * weird typing issue with Postgres. (#178) by James Harton [(#178)](https://github.com/ash-project/ash_sql/pull/178) 158 | 159 | ### Improvements: 160 | 161 | * Support calling immutable version of `ash_raise_error` (#175) by [@stevebrambilla](https://github.com/stevebrambilla) [(#175)](https://github.com/ash-project/ash_sql/pull/175) 162 | 163 | * add immutable_errors? to sql behaviour by [@stevebrambilla](https://github.com/stevebrambilla) [(#175)](https://github.com/ash-project/ash_sql/pull/175) 164 | 165 | ## [v0.3.0](https://github.com/ash-project/ash_sql/compare/v0.2.93...v0.3.0) (2025-09-29) 166 | 167 | 168 | 169 | 170 | ### Features: 171 | 172 | * implemented the SQL translation for Has/Intersects functions (#176) by Abdessabour Moutik [(#176)](https://github.com/ash-project/ash_sql/pull/176) 173 | 174 | ### Bug Fixes: 175 | 176 | * don't add unnecessary option to `relationship_paths` by [@zachdaniel](https://github.com/zachdaniel) 177 | 178 | ## [v0.2.93](https://github.com/ash-project/ash_sql/compare/v0.2.92...v0.2.93) (2025-09-19) 179 | 180 | 181 | 182 | 183 | ### Bug Fixes: 184 | 185 | * include all aggregates in joined query by [@zachdaniel](https://github.com/zachdaniel) 186 | 187 | * handle arrays from get_path calls by [@zachdaniel](https://github.com/zachdaniel) 188 | 189 | * use `?` operator for `in` in jsonb extract case by [@zachdaniel](https://github.com/zachdaniel) 190 | 191 | * match on 4-tuple case for composite types by [@zachdaniel](https://github.com/zachdaniel) 192 | 193 | * properly add parent referenced aggregates while joining by [@zachdaniel](https://github.com/zachdaniel) 194 | 195 | * properly avoid duplicate distincts applied to queries by [@zachdaniel](https://github.com/zachdaniel) 196 | 197 | ## [v0.2.92](https://github.com/ash-project/ash_sql/compare/v0.2.91...v0.2.92) (2025-09-01) 198 | 199 | 200 | 201 | 202 | ### Bug Fixes: 203 | 204 | * retain joined relationships for distinct requirements by [@zachdaniel](https://github.com/zachdaniel) 205 | 206 | ## [v0.2.91](https://github.com/ash-project/ash_sql/compare/v0.2.90...v0.2.91) (2025-08-31) 207 | 208 | 209 | 210 | 211 | ### Bug Fixes: 212 | 213 | * handle case where sort is not set in bindings by [@zachdaniel](https://github.com/zachdaniel) 214 | 215 | ## [v0.2.90](https://github.com/ash-project/ash_sql/compare/v0.2.89...v0.2.90) (2025-08-21) 216 | 217 | 218 | 219 | 220 | ### Bug Fixes: 221 | 222 | * Sanitize distinct in joins (#168) by [@jechol](https://github.com/jechol) 223 | 224 | * don't distinct aggregate subqueries by [@zachdaniel](https://github.com/zachdaniel) 225 | 226 | * Expand distinct with sort order (#162) by Kenneth Kostrešević 227 | 228 | ### Improvements: 229 | 230 | * support unrelated aggregates (#164) by [@zachdaniel](https://github.com/zachdaniel) 231 | 232 | ## [v0.2.89](https://github.com/ash-project/ash_sql/compare/v0.2.88...v0.2.89) (2025-07-25) 233 | 234 | 235 | 236 | 237 | ### Bug Fixes: 238 | 239 | * pull tenant from query properly by [@zachdaniel](https://github.com/zachdaniel) 240 | 241 | ## [v0.2.88](https://github.com/ash-project/ash_sql/compare/v0.2.87...v0.2.88) (2025-07-23) 242 | 243 | 244 | 245 | 246 | ### Bug Fixes: 247 | 248 | * add missing pattern match on exists aggregate by [@zachdaniel](https://github.com/zachdaniel) 249 | 250 | ## [v0.2.87](https://github.com/ash-project/ash_sql/compare/v0.2.86...v0.2.87) (2025-07-22) 251 | 252 | 253 | 254 | 255 | ### Bug Fixes: 256 | 257 | * include references within `exists` while building calculation joins by [@zachdaniel](https://github.com/zachdaniel) 258 | 259 | * make it clear that we don't support aggregates w/ modify_query by [@zachdaniel](https://github.com/zachdaniel) 260 | 261 | ## [v0.2.86](https://github.com/ash-project/ash_sql/compare/v0.2.85...v0.2.86) (2025-07-17) 262 | 263 | 264 | 265 | 266 | ### Bug Fixes: 267 | 268 | * ensure aggregates set `refs_at_path` and calc hydration uses them by [@zachdaniel](https://github.com/zachdaniel) 269 | 270 | * ensure that decimal-producing calculations cast args as decimals by [@zachdaniel](https://github.com/zachdaniel) 271 | 272 | ## [v0.2.85](https://github.com/ash-project/ash_sql/compare/v0.2.84...v0.2.85) (2025-07-09) 273 | 274 | 275 | 276 | 277 | ### Bug Fixes: 278 | 279 | * ensure we join nested parent references properly by [@zachdaniel](https://github.com/zachdaniel) 280 | 281 | ## [v0.2.84](https://github.com/ash-project/ash_sql/compare/v0.2.83...v0.2.84) (2025-07-02) 282 | 283 | 284 | 285 | 286 | ### Bug Fixes: 287 | 288 | * handle parent paths in first relationship of exists path by [@zachdaniel](https://github.com/zachdaniel) 289 | 290 | ## [v0.2.83](https://github.com/ash-project/ash_sql/compare/v0.2.82...v0.2.83) (2025-06-25) 291 | 292 | 293 | 294 | 295 | ### Bug Fixes: 296 | 297 | * ensure calculations are properly type cast by [@zachdaniel](https://github.com/zachdaniel) 298 | 299 | ## [v0.2.82](https://github.com/ash-project/ash_sql/compare/v0.2.81...v0.2.82) (2025-06-18) 300 | 301 | 302 | 303 | 304 | ### Improvements: 305 | 306 | * optimize/simplify boolean functions like && and || by [@zachdaniel](https://github.com/zachdaniel) 307 | 308 | ## [v0.2.81](https://github.com/ash-project/ash_sql/compare/v0.2.80...v0.2.81) (2025-06-17) 309 | 310 | 311 | 312 | 313 | ### Improvements: 314 | 315 | * fix another double-type-casting issue by [@zachdaniel](https://github.com/zachdaniel) 316 | 317 | ## [v0.2.80](https://github.com/ash-project/ash_sql/compare/v0.2.79...v0.2.80) (2025-06-12) 318 | 319 | 320 | 321 | 322 | ### Bug Fixes: 323 | 324 | * don't double cast literals by [@zachdaniel](https://github.com/zachdaniel) 325 | 326 | ## [v0.2.79](https://github.com/ash-project/ash_sql/compare/v0.2.78...v0.2.79) (2025-06-10) 327 | 328 | 329 | 330 | 331 | ### Bug Fixes: 332 | 333 | * ensure that subqueries have prefix set on atomic update selection by [@zachdaniel](https://github.com/zachdaniel) 334 | 335 | * apply subquery schema on many-to-many relationships (#143) by kernel-io 336 | 337 | ## [v0.2.78](https://github.com/ash-project/ash_sql/compare/v0.2.77...v0.2.78) (2025-06-05) 338 | 339 | 340 | 341 | 342 | ### Bug Fixes: 343 | 344 | * always cast operator types, even simple ones 345 | 346 | * undo change that prevents casting literals 347 | 348 | ## [v0.2.77](https://github.com/ash-project/ash_sql/compare/v0.2.76...v0.2.77) (2025-06-04) 349 | 350 | 351 | 352 | 353 | ### Bug Fixes: 354 | 355 | * clean up a whole slew of ecto hacks that arent necessary 356 | 357 | * don't implicitly cast all values 358 | 359 | ### Improvements: 360 | 361 | * reduce cases of double-typecasting 362 | 363 | ## [v0.2.76](https://github.com/ash-project/ash_sql/compare/v0.2.75...v0.2.76) (2025-05-23) 364 | 365 | 366 | 367 | 368 | ### Bug Fixes: 369 | 370 | * retain constraints when type casting 371 | 372 | ## [v0.2.75](https://github.com/ash-project/ash_sql/compare/v0.2.74...v0.2.75) (2025-05-06) 373 | 374 | 375 | 376 | 377 | ### Bug Fixes: 378 | 379 | * use higher start bindings to avoid shadowing (#133) 380 | 381 | * fix calculation remapping post-distinct 382 | 383 | ### Improvements: 384 | 385 | * support combination queries (#131) 386 | 387 | * support combination queries 388 | 389 | * Support rem through fragment (#130) 390 | 391 | ## [v0.2.74](https://github.com/ash-project/ash_sql/compare/v0.2.73...v0.2.74) (2025-05-01) 392 | 393 | 394 | 395 | 396 | ### Bug Fixes: 397 | 398 | * don't group aggregates w/ parent refs 399 | 400 | ## [v0.2.73](https://github.com/ash-project/ash_sql/compare/v0.2.72...v0.2.73) (2025-04-29) 401 | 402 | 403 | 404 | 405 | ### Bug Fixes: 406 | 407 | * prefix subqueries for atomic validations 408 | 409 | * change start_of_day 410 | 411 | ## [v0.2.72](https://github.com/ash-project/ash_sql/compare/v0.2.71...v0.2.72) (2025-04-22) 412 | 413 | 414 | 415 | 416 | ### Bug Fixes: 417 | 418 | * prefix subqueries for atomic validations 419 | 420 | * change start_of_day 421 | 422 | ## [v0.2.71](https://github.com/ash-project/ash_sql/compare/v0.2.70...v0.2.71) (2025-04-17) 423 | 424 | 425 | 426 | 427 | ### Bug Fixes: 428 | 429 | * convert tz properly for start_of_day 430 | 431 | ## [v0.2.70](https://github.com/ash-project/ash_sql/compare/v0.2.69...v0.2.70) (2025-04-17) 432 | 433 | 434 | 435 | 436 | ### Bug Fixes: 437 | 438 | * handle query aggregate in aggregate field properly 439 | 440 | ## [v0.2.69](https://github.com/ash-project/ash_sql/compare/v0.2.68...v0.2.69) (2025-04-15) 441 | 442 | 443 | 444 | 445 | ### Bug Fixes: 446 | 447 | * pass correct operation to split_statements (#121) 448 | 449 | ## [v0.2.68](https://github.com/ash-project/ash_sql/compare/v0.2.67...v0.2.68) (2025-04-15) 450 | 451 | 452 | 453 | 454 | ### Bug Fixes: 455 | 456 | * duplicate aggregate pruning was pruning non-duplicates 457 | 458 | * handle map type logic natively in ash_sql instead of extensions 459 | 460 | ## [v0.2.67](https://github.com/ash-project/ash_sql/compare/v0.2.66...v0.2.67) (2025-04-09) 461 | 462 | 463 | 464 | 465 | ### Bug Fixes: 466 | 467 | * we do need to set the binding, but can just give it a unique name 468 | 469 | * remove explicitly set binding. 470 | 471 | ## [v0.2.66](https://github.com/ash-project/ash_sql/compare/v0.2.65...v0.2.66) (2025-03-26) 472 | 473 | 474 | 475 | 476 | ### Bug Fixes: 477 | 478 | * set proper `refs_at_path` for `exists` queries 479 | 480 | ## [v0.2.65](https://github.com/ash-project/ash_sql/compare/v0.2.64...v0.2.65) (2025-03-26) 481 | 482 | 483 | 484 | 485 | ### Bug Fixes: 486 | 487 | * call `.to_tenant` on the ash query, not the ecto query 488 | 489 | ## [v0.2.64](https://github.com/ash-project/ash_sql/compare/v0.2.63...v0.2.64) (2025-03-26) 490 | 491 | 492 | 493 | 494 | ### Improvements: 495 | 496 | * use new fill_template fn (#115) 497 | 498 | ## [v0.2.63](https://github.com/ash-project/ash_sql/compare/v0.2.62...v0.2.63) (2025-03-25) 499 | 500 | 501 | 502 | 503 | ### Bug Fixes: 504 | 505 | * handle embeds typed as json/jsonb/map 506 | 507 | ## [v0.2.62](https://github.com/ash-project/ash_sql/compare/v0.2.61...v0.2.62) (2025-03-18) 508 | 509 | 510 | 511 | 512 | ### Bug Fixes: 513 | 514 | * support aggregate queries that are against aggregates or calcs 515 | 516 | * add handling for multidimensional arrays in `IN` operator 517 | 518 | ## [v0.2.61](https://github.com/ash-project/ash_sql/compare/v0.2.60...v0.2.61) (2025-03-11) 519 | 520 | 521 | 522 | 523 | ### Bug Fixes: 524 | 525 | * don't use embedded resources as expressions 526 | 527 | * hydrate aggregate expressions at the target 528 | 529 | ## [v0.2.60](https://github.com/ash-project/ash_sql/compare/v0.2.59...v0.2.60) (2025-03-03) 530 | 531 | 532 | 533 | 534 | ### Bug Fixes: 535 | 536 | * properly join to parent paths in aggregate filters 537 | 538 | ## [v0.2.59](https://github.com/ash-project/ash_sql/compare/v0.2.58...v0.2.59) (2025-02-27) 539 | 540 | 541 | 542 | 543 | ### Bug Fixes: 544 | 545 | * wrap strpos comparison in parenthesis 546 | 547 | ## [v0.2.58](https://github.com/ash-project/ash_sql/compare/v0.2.57...v0.2.58) (2025-02-25) 548 | 549 | 550 | 551 | 552 | ### Bug Fixes: 553 | 554 | * various binding index fixes 555 | 556 | * use new functions in `ash` for proper expansion 557 | 558 | * use `count()` when no field is provided for count aggregate 559 | 560 | ## [v0.2.57](https://github.com/ash-project/ash_sql/compare/v0.2.56...v0.2.57) (2025-02-17) 561 | 562 | 563 | 564 | 565 | ### Bug Fixes: 566 | 567 | * rewrite loaded calculations in distinct subqueries 568 | 569 | * ensure literal maps are casted to maps in atomic update select 570 | 571 | * cast complex types in operator signatures 572 | 573 | ## [v0.2.56](https://github.com/ash-project/ash_sql/compare/v0.2.55...v0.2.56) (2025-02-11) 574 | 575 | 576 | 577 | 578 | ### Bug Fixes: 579 | 580 | * more consistent tz handling in `start_of_day` 581 | 582 | ## [v0.2.55](https://github.com/ash-project/ash_sql/compare/v0.2.54...v0.2.55) (2025-02-11) 583 | 584 | 585 | 586 | 587 | ### Improvements: 588 | 589 | * add StringPosition expression (#98) (#99) 590 | 591 | ## [v0.2.54](https://github.com/ash-project/ash_sql/compare/v0.2.53...v0.2.54) (2025-02-08) 592 | 593 | 594 | 595 | 596 | ### Bug Fixes: 597 | 598 | * properly join to aggregates in `parent` exprs in relationships 599 | 600 | * handle non-utc timezoned databases 601 | 602 | * join requirements in parent exprs in first relationship of aggregates 603 | 604 | ## [v0.2.53](https://github.com/ash-project/ash_sql/compare/v0.2.52...v0.2.53) (2025-02-05) 605 | 606 | 607 | 608 | 609 | ### Bug Fixes: 610 | 611 | * simplify lateral join source 612 | 613 | ## [v0.2.52](https://github.com/ash-project/ash_sql/compare/v0.2.51...v0.2.52) (2025-02-04) 614 | 615 | 616 | 617 | 618 | ### Bug Fixes: 619 | 620 | * ensure single agg query has bindings 621 | 622 | ## [v0.2.51](https://github.com/ash-project/ash_sql/compare/v0.2.50...v0.2.51) (2025-02-03) 623 | 624 | 625 | 626 | 627 | ### Bug Fixes: 628 | 629 | * don't attempt to cast to `nil` 630 | 631 | * Use modified query instead of original when calling add_single_aggs (#94) 632 | 633 | ## [v0.2.50](https://github.com/ash-project/ash_sql/compare/v0.2.49...v0.2.50) (2025-01-31) 634 | 635 | 636 | 637 | 638 | ### Bug Fixes: 639 | 640 | * properly handle database time zones in `start_of_day/1-2` 641 | 642 | ## [v0.2.49](https://github.com/ash-project/ash_sql/compare/v0.2.48...v0.2.49) (2025-01-30) 643 | 644 | 645 | 646 | 647 | ### Improvements: 648 | 649 | * support `start_of_day/1-2` 650 | 651 | ## [v0.2.48](https://github.com/ash-project/ash_sql/compare/v0.2.47...v0.2.48) (2025-01-23) 652 | 653 | 654 | 655 | 656 | ### Bug Fixes: 657 | 658 | * handle nested many to many binding overlaps 659 | 660 | ## [v0.2.47](https://github.com/ash-project/ash_sql/compare/v0.2.46...v0.2.47) (2025-01-22) 661 | 662 | 663 | 664 | 665 | ### Bug Fixes: 666 | 667 | * properly fetch source query for many to many rels 668 | 669 | ## [v0.2.46](https://github.com/ash-project/ash_sql/compare/v0.2.45...v0.2.46) (2025-01-20) 670 | 671 | 672 | 673 | 674 | ### Improvements: 675 | 676 | * support `no_cast?` in bindings while expr parsing 677 | 678 | ## [v0.2.45](https://github.com/ash-project/ash_sql/compare/v0.2.44...v0.2.45) (2025-01-14) 679 | 680 | 681 | 682 | 683 | ### Bug Fixes: 684 | 685 | * ensure that referenced fields are joined in agg queries 686 | 687 | ## [v0.2.44](https://github.com/ash-project/ash_sql/compare/v0.2.43...v0.2.44) (2025-01-06) 688 | 689 | 690 | 691 | 692 | ### Bug Fixes: 693 | 694 | * filter query by source record ids when lateral joining 695 | 696 | * use `normalize` for string length 697 | 698 | * use right value for resource aggregate default in sort (#85) 699 | 700 | * handle resource aggregate with function default in sort (#84) 701 | 702 | ## [v0.2.43](https://github.com/ash-project/ash_sql/compare/v0.2.42...v0.2.43) (2024-12-26) 703 | 704 | 705 | 706 | 707 | ### Bug Fixes: 708 | 709 | * return `{:empty, query}` on empty atomic changes 710 | 711 | ## [v0.2.42](https://github.com/ash-project/ash_sql/compare/v0.2.41...v0.2.42) (2024-12-20) 712 | 713 | 714 | 715 | 716 | ### Bug Fixes: 717 | 718 | * properly bind many to many relationships in aggregates 719 | 720 | ## [v0.2.41](https://github.com/ash-project/ash_sql/compare/v0.2.40...v0.2.41) (2024-12-12) 721 | 722 | 723 | 724 | 725 | ### Bug Fixes: 726 | 727 | * apply attribute multitenancy on joined resources 728 | 729 | * use lateral join for parent_expr many to many joins 730 | 731 | * ensure join binding is available for join resource in exists 732 | 733 | * add missing pattern for setting group context 734 | 735 | ## [v0.2.40](https://github.com/ash-project/ash_sql/compare/v0.2.39...v0.2.40) (2024-12-06) 736 | 737 | 738 | 739 | 740 | ### Improvements: 741 | 742 | * various fixes to the methodology behind type determination 743 | 744 | ## [v0.2.39](https://github.com/ash-project/ash_sql/compare/v0.2.38...v0.2.39) (2024-11-04) 745 | 746 | 747 | 748 | 749 | ### Bug Fixes: 750 | 751 | * properly reference `from_many?` source binding while joining 752 | 753 | ## [v0.2.38](https://github.com/ash-project/ash_sql/compare/v0.2.37...v0.2.38) (2024-10-29) 754 | 755 | 756 | 757 | 758 | ### Bug Fixes: 759 | 760 | * ensure we join on parent expressions when joining filtered relationships 761 | 762 | ## [v0.2.37](https://github.com/ash-project/ash_sql/compare/v0.2.36...v0.2.37) (2024-10-28) 763 | 764 | 765 | 766 | 767 | ### Bug Fixes: 768 | 769 | * properly determine join style for parent expressions 770 | 771 | * count: use asterisk if not distinct by field is given (#72) 772 | 773 | ## [v0.2.36](https://github.com/ash-project/ash_sql/compare/v0.2.35...v0.2.36) (2024-10-08) 774 | 775 | 776 | 777 | 778 | ### Bug Fixes: 779 | 780 | * properly handle parent bindings in aggregate references 781 | 782 | ## [v0.2.35](https://github.com/ash-project/ash_sql/compare/v0.2.34...v0.2.35) (2024-10-07) 783 | 784 | 785 | 786 | 787 | ### Bug Fixes: 788 | 789 | * properly group aggregates together 790 | 791 | * don't attempt to add multiple filter statements to a single aggregate 792 | 793 | ## [v0.2.34](https://github.com/ash-project/ash_sql/compare/v0.2.33...v0.2.34) (2024-09-27) 794 | 795 | 796 | 797 | 798 | ### Bug Fixes: 799 | 800 | * use `NULL` for cases where we get `nil` values. We actually want `nil` here. 801 | 802 | ## [v0.2.33](https://github.com/ash-project/ash_sql/compare/v0.2.32...v0.2.33) (2024-09-26) 803 | 804 | 805 | 806 | 807 | ### Bug Fixes: 808 | 809 | * don't reorder selects when modifying for subquery presence 810 | 811 | ## [v0.2.25](https://github.com/ash-project/ash_sql/compare/v0.2.24...v0.2.25) (2024-07-22) 812 | 813 | 814 | 815 | 816 | ### Bug Fixes: 817 | 818 | * track subqueries while selecting atomics 819 | 820 | ## [v0.2.24](https://github.com/ash-project/ash_sql/compare/v0.2.23...v0.2.24) (2024-07-17) 821 | 822 | 823 | 824 | 825 | ### Bug Fixes: 826 | 827 | * properly determine `parent_as` bindings for nested joins 828 | 829 | ## [v0.2.23](https://github.com/ash-project/ash_sql/compare/v0.2.22...v0.2.23) (2024-07-17) 830 | 831 | 832 | 833 | 834 | ### Bug Fixes: 835 | 836 | * fix build 837 | 838 | * properly expand resource calculation/aggregates in fields 839 | 840 | ## [v0.2.22](https://github.com/ash-project/ash_sql/compare/v0.2.21...v0.2.22) (2024-07-16) 841 | 842 | 843 | 844 | 845 | ### Bug Fixes: 846 | 847 | * properly adjust calculation expressions before adding to query 848 | 849 | * properly traverse nested maps in non-select contexts 850 | 851 | * pass correct resource down when adding calculation fields 852 | 853 | ## [v0.2.21](https://github.com/ash-project/ash_sql/compare/v0.2.20...v0.2.21) (2024-07-16) 854 | 855 | 856 | 857 | 858 | ### Bug Fixes: 859 | 860 | * move `FILTER` outside of `array_agg` aggregation 861 | 862 | * properly honor `include_nil?` option on sorted first aggregates 863 | 864 | ## [v0.2.20](https://github.com/ash-project/ash_sql/compare/v0.2.19...v0.2.20) (2024-07-15) 865 | 866 | 867 | 868 | 869 | ### Bug Fixes: 870 | 871 | * don't set load on anonymous aggregates 872 | 873 | ## [v0.2.19](https://github.com/ash-project/ash_sql/compare/v0.2.18...v0.2.19) (2024-07-15) 874 | 875 | 876 | 877 | 878 | ### Bug Fixes: 879 | 880 | * match on old return types for `determine_types` code 881 | 882 | ## [v0.2.18](https://github.com/ash-project/ash_sql/compare/v0.2.17...v0.2.18) (2024-07-15) 883 | 884 | 885 | 886 | 887 | ### Bug Fixes: 888 | 889 | * properly set aggregate query context 890 | 891 | ## [v0.2.17](https://github.com/ash-project/ash_sql/compare/v0.2.16...v0.2.17) (2024-07-14) 892 | 893 | 894 | 895 | 896 | ### Improvements: 897 | 898 | * use `determine_types/3` in callback 899 | 900 | ## [v0.2.16](https://github.com/ash-project/ash_sql/compare/v0.2.15...v0.2.16) (2024-07-14) 901 | 902 | 903 | 904 | 905 | ### Bug Fixes: 906 | 907 | * cast atomics when creating expressions 908 | 909 | ### Improvements: 910 | 911 | * support latest format for type determination type 912 | 913 | ## [v0.2.15](https://github.com/ash-project/ash_sql/compare/v0.2.14...v0.2.15) (2024-07-13) 914 | 915 | 916 | 917 | 918 | ### Bug Fixes: 919 | 920 | * use original field for type signal when selecting atomics 921 | 922 | ## [v0.2.14](https://github.com/ash-project/ash_sql/compare/v0.2.13...v0.2.14) (2024-07-13) 923 | 924 | 925 | 926 | 927 | ### Improvements: 928 | 929 | * use explicit `NULL` fragment in error type hints 930 | 931 | ## [v0.2.13](https://github.com/ash-project/ash_sql/compare/v0.2.12...v0.2.13) (2024-07-12) 932 | 933 | 934 | 935 | ### Bug Fixes: 936 | 937 | * use expression type for atomic updates 938 | 939 | ## [v0.2.12](https://github.com/ash-project/ash_sql/compare/v0.2.11...v0.2.12) (2024-07-12) 940 | 941 | 942 | 943 | 944 | ### Bug Fixes: 945 | 946 | * properly handle nested `nil` filters in boolean statements 947 | 948 | ## [v0.2.11](https://github.com/ash-project/ash_sql/compare/v0.2.10...v0.2.11) (2024-07-11) 949 | 950 | 951 | 952 | 953 | ### Improvements: 954 | 955 | * select pkey so data layers don't have to 956 | 957 | ## [v0.2.10](https://github.com/ash-project/ash_sql/compare/v0.2.9...v0.2.10) (2024-07-08) 958 | 959 | 960 | 961 | 962 | ### Bug Fixes: 963 | 964 | * ensure selected atomics are also reversed 965 | 966 | ## [v0.2.9](https://github.com/ash-project/ash_sql/compare/v0.2.8...v0.2.9) (2024-07-08) 967 | 968 | 969 | 970 | 971 | ### Bug Fixes: 972 | 973 | * retain original order for atomics statements 974 | 975 | ## [v0.2.8](https://github.com/ash-project/ash_sql/compare/v0.2.7...v0.2.8) (2024-07-06) 976 | 977 | 978 | 979 | 980 | ### Improvements: 981 | 982 | * handle `{:array, :map}` stored as `:map` 983 | 984 | ## [v0.2.7](https://github.com/ash-project/ash_sql/compare/v0.2.6...v0.2.7) (2024-06-27) 985 | 986 | 987 | 988 | 989 | ### Bug Fixes: 990 | 991 | * prefer resource's static prefix over current query's prefix 992 | 993 | ## [v0.2.6](https://github.com/ash-project/ash_sql/compare/v0.2.5...v0.2.6) (2024-06-18) 994 | 995 | 996 | 997 | 998 | ### Bug Fixes: 999 | 1000 | * ensure we always honor `atomics_at_binding` option from data layer 1001 | 1002 | ## [v0.2.5](https://github.com/ash-project/ash_sql/compare/v0.2.4...v0.2.5) (2024-06-13) 1003 | 1004 | 1005 | 1006 | 1007 | ### Bug Fixes: 1008 | 1009 | * properly remap selects on nested subqueries 1010 | 1011 | ## [v0.2.4](https://github.com/ash-project/ash_sql/compare/v0.2.3...v0.2.4) (2024-06-13) 1012 | 1013 | 1014 | 1015 | 1016 | ### Bug Fixes: 1017 | 1018 | * remap nested selects when sort requires a subquery 1019 | 1020 | * don't create dynamics for map atomics where there are no expressions 1021 | 1022 | ### Improvements: 1023 | 1024 | * only use `jsonb_build_object` for expressions, not literals 1025 | 1026 | ## [v0.2.3](https://github.com/ash-project/ash_sql/compare/v0.2.2...v0.2.3) (2024-06-06) 1027 | 1028 | 1029 | 1030 | 1031 | ### Bug Fixes: 1032 | 1033 | * various fixes to retain lateral join context 1034 | 1035 | ## [v0.2.2](https://github.com/ash-project/ash_sql/compare/v0.2.1...v0.2.2) (2024-06-05) 1036 | 1037 | 1038 | 1039 | 1040 | ### Bug Fixes: 1041 | 1042 | * carry over tenant in joined queries 1043 | 1044 | ## [v0.2.1](https://github.com/ash-project/ash_sql/compare/v0.2.0...v0.2.1) (2024-06-02) 1045 | 1046 | 1047 | 1048 | 1049 | ### Improvements: 1050 | 1051 | * select dynamics uses `__new_` prefix 1052 | 1053 | ## [v0.2.0](https://github.com/ash-project/ash_sql/compare/v0.1.3...v0.2.0) (2024-05-29) 1054 | 1055 | 1056 | 1057 | 1058 | ### Features: 1059 | 1060 | * add auto dispatch of dynamic_expr calls to behaviour module (#33) 1061 | 1062 | * add auto dispatch of dynamic_expr calls to behaviour module 1063 | 1064 | ### Bug Fixes: 1065 | 1066 | * match on new & old parameterized types 1067 | 1068 | ### Improvements: 1069 | 1070 | * support selecting atomic results into a subquery, and using those as the atomic values 1071 | 1072 | ## [v0.1.3](https://github.com/ash-project/ash_sql/compare/v0.1.2...v0.1.3) (2024-05-22) 1073 | 1074 | 1075 | 1076 | 1077 | ### Bug Fixes: 1078 | 1079 | * handle anonymous sorting aggregates 1080 | 1081 | * properly set aggregate source binding when adding aggregate calculations 1082 | 1083 | * use period notation to access aggregate context fields (#30) 1084 | 1085 | * use SQL standard = instead of non standard == (#28) 1086 | 1087 | ## [v0.1.2](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.20...v0.1.2) (2024-05-10) 1088 | 1089 | 1090 | 1091 | 1092 | ## [v0.1.1-rc.20](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.19...v0.1.1-rc.20) (2024-05-08) 1093 | 1094 | 1095 | 1096 | 1097 | ### Bug Fixes: 1098 | 1099 | * don't use `fragment("1")` because ecto requires a proper select 1100 | 1101 | ## [v0.1.1-rc.19](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.18...v0.1.1-rc.19) (2024-05-05) 1102 | 1103 | 1104 | 1105 | 1106 | ### Bug Fixes: 1107 | 1108 | * use calculation context, and set calculation constraints for aggregates 1109 | 1110 | ## [v0.1.1-rc.18](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.17...v0.1.1-rc.18) (2024-05-05) 1111 | 1112 | 1113 | 1114 | 1115 | ### Bug Fixes: 1116 | 1117 | * don't use `ilike` if its not supported 1118 | 1119 | * use type for now expr if available 1120 | 1121 | ## [v0.1.1-rc.17](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.16...v0.1.1-rc.17) (2024-05-02) 1122 | 1123 | 1124 | 1125 | 1126 | ### Bug Fixes: 1127 | 1128 | * use manual relationship impl for exists subqueries 1129 | 1130 | ## [v0.1.1-rc.16](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.15...v0.1.1-rc.16) (2024-05-01) 1131 | 1132 | 1133 | 1134 | 1135 | ### Bug Fixes: 1136 | 1137 | * hydrate & fill template for related queries 1138 | 1139 | ## [v0.1.1-rc.15](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.14...v0.1.1-rc.15) (2024-04-29) 1140 | 1141 | 1142 | 1143 | 1144 | ### Bug Fixes: 1145 | 1146 | * properly support custom expressions 1147 | 1148 | ## [v0.1.1-rc.14](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.13...v0.1.1-rc.14) (2024-04-29) 1149 | 1150 | 1151 | 1152 | 1153 | ### Bug Fixes: 1154 | 1155 | * fix argument order in AshSql.Bindings.default_bindings/4 1156 | 1157 | * query_with_atomics pattern matching error 1158 | 1159 | * fix argument order in AshSql.Bindings.default_bindings/4 1160 | 1161 | ## [v0.1.1-rc.13](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.12...v0.1.1-rc.13) (2024-04-27) 1162 | 1163 | 1164 | 1165 | 1166 | ### Improvements: 1167 | 1168 | * better inner-join-ability detection 1169 | 1170 | ## [v0.1.1-rc.12](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.11...v0.1.1-rc.12) (2024-04-26) 1171 | 1172 | 1173 | 1174 | 1175 | ### Improvements: 1176 | 1177 | * better type casting logic 1178 | 1179 | ## [v0.1.1-rc.11](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.10...v0.1.1-rc.11) (2024-04-23) 1180 | 1181 | 1182 | 1183 | 1184 | ### Bug Fixes: 1185 | 1186 | * ensure tenant is properly set in aggregates 1187 | 1188 | * properly pass context through when expanding calculations in aggregates 1189 | 1190 | ## [v0.1.1-rc.10](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.9...v0.1.1-rc.10) (2024-04-22) 1191 | 1192 | 1193 | 1194 | 1195 | ### Improvements: 1196 | 1197 | * optimize `contains` when used with literal strings 1198 | 1199 | ## [v0.1.1-rc.9](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.8...v0.1.1-rc.9) (2024-04-22) 1200 | 1201 | 1202 | 1203 | 1204 | ### Bug Fixes: 1205 | 1206 | * make `strpos_function` overridable (sqlite uses `instr`) 1207 | 1208 | ## [v0.1.1-rc.8](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.7...v0.1.1-rc.8) (2024-04-22) 1209 | 1210 | 1211 | 1212 | 1213 | ### Bug Fixes: 1214 | 1215 | * handle non-literal lists in string join 1216 | 1217 | ## [v0.1.1-rc.7](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.6...v0.1.1-rc.7) (2024-04-20) 1218 | 1219 | 1220 | 1221 | 1222 | ### Bug Fixes: 1223 | 1224 | * ensure that `from_many?` is properly honored 1225 | 1226 | * ensure applied query gets joined 1227 | 1228 | * apply related filter inside of related subquery 1229 | 1230 | ## [v0.1.1-rc.6](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.5...v0.1.1-rc.6) (2024-04-12) 1231 | 1232 | 1233 | 1234 | 1235 | ### Improvements: 1236 | 1237 | * apply aggregate filters on first join aggregates 1238 | 1239 | ## [v0.1.1-rc.5](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.4...v0.1.1-rc.5) (2024-04-11) 1240 | 1241 | 1242 | 1243 | 1244 | ### Bug Fixes: 1245 | 1246 | * don't use to_tenant 1247 | 1248 | * loosen elixir requirements 1249 | 1250 | ### Improvements: 1251 | 1252 | * automatically wrap fragments in parenthesis 1253 | 1254 | * remove unnecessary parenthesis from builtin fragments 1255 | 1256 | ## [v0.1.1-rc.4](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.3...v0.1.1-rc.4) (2024-04-05) 1257 | 1258 | 1259 | 1260 | 1261 | ### Improvements: 1262 | 1263 | * loosen ash rc restriction 1264 | 1265 | ## [v0.1.1-rc.3](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.2...v0.1.1-rc.3) (2024-04-01) 1266 | 1267 | 1268 | 1269 | 1270 | ### Bug Fixes: 1271 | 1272 | * fixes for `ash_postgres` 1273 | 1274 | ## [v0.1.1-rc.2](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.1...v0.1.1-rc.2) (2024-04-01) 1275 | 1276 | 1277 | 1278 | 1279 | ### Improvements: 1280 | 1281 | * refactoring out and parameterization to support ash_sqlite 1282 | 1283 | ## [v0.1.1-rc.1](https://github.com/ash-project/ash_sql/compare/v0.1.1-rc.0...v0.1.1-rc.1) (2024-04-01) 1284 | 1285 | 1286 | 1287 | 1288 | ### Improvements: 1289 | 1290 | * remove postgres-specific copy-pasta 1291 | 1292 | ## [v0.1.1-rc.0](https://github.com/ash-project/ash_sql/compare/v0.1.0...v0.1.1-rc.0) (2024-04-01) 1293 | 1294 | 1295 | 1296 | 1297 | ## [v0.1.0](https://github.com/ash-project/ash_sql/compare/v0.1.0...v0.1.0) (2024-04-01) 1298 | 1299 | 1300 | 1301 | 1302 | ### Improvements: 1303 | 1304 | * extract a bunch of things out of AshPostgres 1305 | -------------------------------------------------------------------------------- /lib/join.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_sql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSql.Join do 6 | @moduledoc false 7 | import Ecto.Query, only: [from: 2, subquery: 1] 8 | 9 | require Ash.Query 10 | 11 | alias Ash.Query.{Not, Ref} 12 | 13 | @known_inner_join_operators [ 14 | Ash.Query.Operator.Eq, 15 | Ash.Query.Operator.GreaterThan, 16 | Ash.Query.Operator.GreaterThanOrEqual, 17 | Ash.Query.Operator.In, 18 | Ash.Query.Operator.LessThanOrEqual, 19 | Ash.Query.Operator.LessThan, 20 | Ash.Query.Operator.NotEq 21 | ] 22 | 23 | @known_inner_join_functions [ 24 | Ash.Query.Function.Ago, 25 | Ash.Query.Function.Contains, 26 | Ash.Query.Function.At, 27 | Ash.Query.Function.DateAdd, 28 | Ash.Query.Function.DateTimeAdd, 29 | Ash.Query.Function.FromNow, 30 | Ash.Query.Function.GetPath, 31 | Ash.Query.Function.Length, 32 | Ash.Query.Function.Minus, 33 | Ash.Query.Function.Round, 34 | Ash.Query.Function.StringDowncase, 35 | Ash.Query.Function.StringJoin, 36 | Ash.Query.Function.StringLength, 37 | Ash.Query.Function.StringSplit, 38 | Ash.Query.Function.StringTrim 39 | ] 40 | 41 | def join_all_relationships( 42 | query, 43 | filter, 44 | opts \\ [], 45 | relationship_paths \\ nil, 46 | path \\ [], 47 | source \\ nil, 48 | sort? \\ true, 49 | join_filters \\ nil, 50 | parent_bindings \\ nil, 51 | no_inner_join? \\ false 52 | ) 53 | 54 | # simple optimization for common cases 55 | def join_all_relationships( 56 | query, 57 | filter, 58 | _opts, 59 | relationship_paths, 60 | _path, 61 | _source, 62 | _sort?, 63 | _join_filters, 64 | _parent_bindings, 65 | _no_inner_join? 66 | ) 67 | when is_nil(relationship_paths) and filter in [nil, true, false] do 68 | {:ok, query} 69 | end 70 | 71 | def join_all_relationships( 72 | query, 73 | filter, 74 | opts, 75 | relationship_paths, 76 | path, 77 | source, 78 | sort?, 79 | join_filters, 80 | parent_query, 81 | no_inner_join? 82 | ) do 83 | no_inner_join? = 84 | no_inner_join? || query.__ash_bindings__.context[:data_layer][:no_inner_join?] 85 | 86 | case join_parent_paths(query, filter, relationship_paths) do 87 | {:ok, query} -> 88 | relationship_paths = 89 | relationship_paths || 90 | filter 91 | |> Ash.Filter.relationship_paths() 92 | |> to_joins(filter, query.__ash_bindings__.resource) 93 | 94 | # Add parent relationship paths from relationship filters with parent expressions 95 | # But only if we haven't already processed them (to avoid infinite recursion) 96 | {query, relationship_paths} = 97 | if query.__ash_bindings__[:processed_parent_paths] do 98 | {query, relationship_paths} 99 | else 100 | parent_relationship_paths = 101 | extract_parent_paths_from_exists_relationships( 102 | filter, 103 | query.__ash_bindings__.resource 104 | ) 105 | 106 | updated_query = put_in(query.__ash_bindings__[:processed_parent_paths], true) 107 | {updated_query, relationship_paths ++ parent_relationship_paths} 108 | end 109 | 110 | Enum.reduce_while(relationship_paths, {:ok, query}, fn 111 | {_join_type, []}, {:ok, query} -> 112 | {:cont, {:ok, query}} 113 | 114 | {join_type, [relationship | rest_rels]}, {:ok, query} -> 115 | join_type = 116 | if no_inner_join? do 117 | :left 118 | else 119 | join_type 120 | end 121 | 122 | source = source || relationship.source 123 | 124 | current_path = path ++ [relationship] 125 | 126 | current_join_type = join_type 127 | 128 | look_for_join_types = 129 | case join_type do 130 | :left -> 131 | [:left, :inner] 132 | 133 | :inner -> 134 | [:left, :inner] 135 | 136 | other -> 137 | [other] 138 | end 139 | 140 | binding = 141 | get_binding(source, Enum.map(current_path, & &1.name), query, look_for_join_types) 142 | 143 | # We can't reuse joins if we're adding filters/have a separate parent binding 144 | if is_nil(join_filters) && is_nil(parent_query) && binding do 145 | case join_all_relationships( 146 | query, 147 | filter, 148 | opts, 149 | [{join_type, rest_rels}], 150 | current_path, 151 | source, 152 | sort? 153 | ) do 154 | {:ok, query} -> 155 | {:cont, {:ok, query}} 156 | 157 | {:error, error} -> 158 | {:halt, {:error, error}} 159 | end 160 | else 161 | case join_relationship( 162 | query, 163 | relationship, 164 | Enum.map(path, & &1.name), 165 | current_join_type, 166 | source, 167 | filter, 168 | sort?, 169 | join_filters[Enum.map(current_path, & &1.name)] 170 | ) do 171 | {:ok, joined_query} -> 172 | joined_query_with_distinct = add_distinct(relationship, join_type, joined_query) 173 | 174 | case join_all_relationships( 175 | joined_query_with_distinct, 176 | filter, 177 | opts, 178 | [{join_type, rest_rels}], 179 | current_path, 180 | source, 181 | sort?, 182 | join_filters, 183 | Map.update!( 184 | joined_query_with_distinct, 185 | :__ash_bindings__, 186 | fn ash_bindings -> 187 | Map.put( 188 | ash_bindings, 189 | :refs_at_path, 190 | Enum.map(path, & &1.name) ++ [relationship.name] 191 | ) 192 | end 193 | ) 194 | ) do 195 | {:ok, query} -> 196 | {:cont, {:ok, query}} 197 | 198 | {:error, error} -> 199 | {:halt, {:error, error}} 200 | end 201 | 202 | {:error, error} -> 203 | {:halt, {:error, error}} 204 | end 205 | end 206 | end) 207 | 208 | {:error, error} -> 209 | {:error, error} 210 | end 211 | end 212 | 213 | @doc false 214 | def parent_expr(filter) do 215 | filter 216 | |> Ash.Filter.map(fn 217 | %Ash.Query.Parent{expr: expr} -> 218 | {:halt, expr} 219 | 220 | %Ash.Query.Ref{} -> 221 | nil 222 | 223 | %Ash.Query.Exists{} -> 224 | nil 225 | 226 | other -> 227 | other 228 | end) 229 | end 230 | 231 | defp join_parent_paths(query, filter, nil) do 232 | case query.__ash_bindings__[:lateral_join_source_query] do 233 | nil -> 234 | {:ok, query} 235 | 236 | lateral_join_source_query -> 237 | case join_all_relationships(lateral_join_source_query, parent_expr(filter)) do 238 | {:ok, lateral_join_source_query} -> 239 | {:ok, 240 | put_in(query.__ash_bindings__.lateral_join_source_query, lateral_join_source_query) 241 | |> Map.update!(:__ash_bindings__, fn bindings -> 242 | Map.put( 243 | bindings, 244 | :parent_bindings, 245 | Map.put(lateral_join_source_query.__ash_bindings__, :parent?, true) 246 | ) 247 | end)} 248 | 249 | {:error, error} -> 250 | {:error, error} 251 | end 252 | end 253 | end 254 | 255 | defp join_parent_paths(query, _filter, _relationship_paths) do 256 | {:ok, query} 257 | end 258 | 259 | defp to_joins(paths, filter, resource) do 260 | paths 261 | |> Enum.reject(&(&1 == [])) 262 | |> Enum.map(fn path -> 263 | if can_inner_join?(path, filter) do 264 | {:inner, 265 | relationship_path_to_relationships( 266 | resource, 267 | path 268 | )} 269 | else 270 | {:left, 271 | relationship_path_to_relationships( 272 | resource, 273 | path 274 | )} 275 | end 276 | end) 277 | end 278 | 279 | defp extract_parent_paths_from_exists_relationships(filter, resource) do 280 | # Use flat_map to accumulate parent paths from exists expressions 281 | parent_paths = 282 | Ash.Filter.flat_map(filter, fn 283 | %Ash.Query.Exists{at_path: at_path, path: [first_rel_name | _rest]} = _exists -> 284 | resource = Ash.Resource.Info.related(resource, at_path) 285 | # Get the first relationship 286 | relationship = Ash.Resource.Info.relationship(resource, first_rel_name) 287 | 288 | if relationship && relationship.filter do 289 | # Extract parent expressions from the relationship filter 290 | extract_parent_refs_from_filter(relationship.filter) 291 | else 292 | [] 293 | end 294 | 295 | _other -> 296 | [] 297 | end) 298 | 299 | # Convert to relationship path tuples and return 300 | parent_paths 301 | |> Enum.uniq() 302 | |> Enum.map(fn path -> 303 | relationships = relationship_path_to_relationships(resource, path) 304 | {:left, relationships} 305 | end) 306 | end 307 | 308 | defp extract_parent_refs_from_filter(filter) do 309 | # Use flat_map to accumulate relationship paths from parent expressions 310 | Ash.Filter.flat_map(filter, fn 311 | %Ash.Query.Parent{expr: expr} = _parent -> 312 | # Extract refs from parent expression 313 | expr 314 | |> Ash.Filter.list_refs() 315 | |> Enum.map(& &1.relationship_path) 316 | |> Enum.reject(&Enum.empty?/1) 317 | 318 | _other -> 319 | [] 320 | end) 321 | |> Enum.uniq() 322 | end 323 | 324 | def relationship_path_to_relationships(resource, path, acc \\ []) 325 | def relationship_path_to_relationships(_resource, [], acc), do: Enum.reverse(acc) 326 | 327 | def relationship_path_to_relationships(resource, [name | rest], acc) do 328 | relationship = Ash.Resource.Info.relationship(resource, name) 329 | 330 | if !relationship do 331 | raise "no such relationship #{inspect(resource)}.#{name}" 332 | end 333 | 334 | relationship_path_to_relationships(relationship.destination, rest, [relationship | acc]) 335 | end 336 | 337 | def related_subquery( 338 | relationship, 339 | root_query, 340 | opts \\ [] 341 | ) do 342 | on_parent_expr = Keyword.get(opts, :on_parent_expr, & &1) 343 | on_subquery = Keyword.get(opts, :on_subquery, & &1) 344 | filter = Keyword.get(opts, :filter, nil) 345 | filter_subquery? = Keyword.get(opts, :filter_subquery?, false) 346 | skip_distinct_for_first_rel? = Keyword.get(opts, :skip_distinct_for_first_rel?, false) 347 | 348 | with {:ok, query} <- related_query(relationship, root_query, opts) do 349 | has_parent_expr? = 350 | opts[:require_lateral?] || 351 | !!query.__ash_bindings__.context[:data_layer][:has_parent_expr?] || 352 | not is_nil(query.limit) 353 | 354 | query = 355 | if has_parent_expr? do 356 | on_parent_expr.(query) 357 | else 358 | query 359 | end 360 | 361 | query = 362 | if skip_distinct_for_first_rel? do 363 | Map.update!(query, :__ash_bindings__, &Map.put(&1, :skip_distinct_for_first_rel?, true)) 364 | else 365 | query 366 | end 367 | 368 | query = on_subquery.(query) 369 | 370 | query = limit_from_many(query, relationship, filter, filter_subquery?, opts) 371 | 372 | query = 373 | if opts[:return_subquery?] do 374 | subquery(query) 375 | else 376 | if Enum.empty?(query.joins) && Enum.empty?(query.order_bys) && Enum.empty?(query.wheres) do 377 | query 378 | else 379 | from(row in subquery(query), as: ^(opts[:start_bindings_at] || 0)) 380 | |> AshSql.Bindings.default_bindings( 381 | relationship.destination, 382 | query.__ash_bindings__.sql_behaviour 383 | ) 384 | |> AshSql.Bindings.merge_expr_accumulator( 385 | query.__ash_bindings__.expression_accumulator 386 | ) 387 | |> Map.update!( 388 | :__ash_bindings__, 389 | fn bindings -> 390 | bindings 391 | |> Map.put(:current, query.__ash_bindings__.current) 392 | |> put_in([:context, :data_layer], %{ 393 | has_parent_expr?: has_parent_expr? 394 | }) 395 | end 396 | ) 397 | end 398 | end 399 | 400 | {:ok, query} 401 | end 402 | end 403 | 404 | defp related_query(relationship, query, opts) do 405 | sort? = Keyword.get(opts, :sort?, false) 406 | filter = Keyword.get(opts, :filter, nil) 407 | filter_subquery? = Keyword.get(opts, :filter_subquery?, false) 408 | parent_resources = Keyword.get(opts, :parent_stack, [relationship.source]) 409 | 410 | read_action = 411 | relationship.read_action || 412 | Ash.Resource.Info.primary_action!(relationship.destination, :read).name 413 | 414 | context = Map.delete(query.__ash_bindings__.context, :data_layer) 415 | 416 | tenant = query.__ash_bindings__.context[:private][:tenant] 417 | 418 | relationship.destination 419 | |> Ash.Query.new() 420 | |> Ash.Query.set_context(context) 421 | |> Ash.Query.set_context(%{data_layer: %{in_group?: !!opts[:in_group?]}}) 422 | |> Ash.Query.set_context(%{ 423 | data_layer: %{ 424 | table: nil, 425 | start_bindings_at: opts[:start_bindings_at] || 0 426 | } 427 | }) 428 | |> Ash.Query.set_context(relationship.context) 429 | |> Ash.Query.do_filter(relationship.filter, parent_stack: parent_resources) 430 | |> then(fn query -> 431 | if Map.get(relationship, :from_many?) && filter_subquery? do 432 | query 433 | else 434 | Ash.Query.do_filter(query, filter, parent_stack: parent_resources) 435 | end 436 | end) 437 | |> Ash.Query.do_filter(opts[:apply_filter], parent_stack: parent_resources) 438 | |> then(fn query -> 439 | if query.__validated_for_action__ == read_action do 440 | query 441 | else 442 | Ash.Query.for_read(query, read_action, %{}, 443 | actor: context[:private][:actor], 444 | tenant: context[:private][:tenant] 445 | ) 446 | end 447 | end) 448 | |> Ash.Query.unset([:distinct, :select, :limit, :offset]) 449 | |> handle_attribute_multitenancy(tenant) 450 | |> hydrate_refs(context[:private][:actor]) 451 | |> then(fn query -> 452 | if sort? do 453 | query 454 | |> Ash.Query.unset(:sort) 455 | |> Ash.Query.sort(relationship.sort || query.sort) 456 | else 457 | query 458 | |> Ash.Query.unset(:sort) 459 | end 460 | end) 461 | |> set_has_parent_expr_context(relationship) 462 | |> case do 463 | %{valid?: true} = related_query -> 464 | parent_bindings = 465 | query.__ash_bindings__ 466 | |> Map.put(:refs_at_path, List.wrap(opts[:refs_at_path])) 467 | |> then(fn bindings -> 468 | if opts[:skip_distinct_for_first_rel?] do 469 | Map.put(bindings, :skip_distinct_for_first_rel?, true) 470 | else 471 | bindings 472 | end 473 | end) 474 | 475 | Ash.Query.data_layer_query( 476 | Ash.Query.set_context(related_query, %{ 477 | data_layer: %{ 478 | parent_bindings: parent_bindings 479 | } 480 | }) 481 | ) 482 | |> case do 483 | {:ok, ecto_query} -> 484 | ecto_query = 485 | if opts[:skip_distinct_for_first_rel?] do 486 | Map.update!( 487 | ecto_query, 488 | :__ash_bindings__, 489 | &Map.put(&1, :skip_distinct_for_first_rel?, true) 490 | ) 491 | else 492 | ecto_query 493 | end 494 | 495 | {:ok, 496 | ecto_query 497 | |> set_join_prefix(query, Map.get(relationship, :through, relationship.destination)) 498 | |> Ecto.Query.exclude(:select)} 499 | 500 | {:error, error} -> 501 | {:error, error} 502 | end 503 | 504 | %{errors: errors} -> 505 | {:error, errors} 506 | end 507 | end 508 | 509 | @doc false 510 | def handle_attribute_multitenancy(query, tenant) do 511 | if tenant && Ash.Resource.Info.multitenancy_strategy(query.resource) == :attribute do 512 | multitenancy_attribute = Ash.Resource.Info.multitenancy_attribute(query.resource) 513 | 514 | if multitenancy_attribute do 515 | {m, f, a} = Ash.Resource.Info.multitenancy_parse_attribute(query.resource) 516 | attribute_value = apply(m, f, [query.to_tenant | a]) 517 | 518 | query 519 | |> Ash.Query.set_tenant(tenant) 520 | |> Ash.Query.filter(^Ash.Expr.ref(multitenancy_attribute) == ^attribute_value) 521 | else 522 | query 523 | end 524 | else 525 | query 526 | end 527 | end 528 | 529 | @doc false 530 | def hydrate_refs(query, actor) do 531 | query.filter 532 | |> Ash.Expr.fill_template( 533 | actor: actor, 534 | tenant: query.to_tenant, 535 | args: %{}, 536 | context: query.context 537 | ) 538 | |> Ash.Filter.hydrate_refs(%{resource: query.resource}) 539 | |> case do 540 | {:ok, result} -> %{query | filter: result} 541 | {:error, error} -> Ash.Query.add_error(query, error) 542 | end 543 | end 544 | 545 | defp limit_from_many( 546 | query, 547 | %{from_many?: true, destination: destination}, 548 | filter, 549 | filter_subquery?, 550 | opts 551 | ) do 552 | if filter_subquery? do 553 | query = 554 | from(row in Ecto.Query.subquery(from(row in query, limit: 1)), 555 | as: ^query.__ash_bindings__.root_binding 556 | ) 557 | |> Map.put(:__ash_bindings__, query.__ash_bindings__) 558 | |> AshSql.Bindings.default_bindings( 559 | destination, 560 | query.__ash_bindings__.sql_behaviour 561 | ) 562 | 563 | {:ok, query} = AshSql.Filter.filter(query, filter, query.__ash_bindings__.resource) 564 | 565 | if opts[:select_star?] do 566 | from(row in Ecto.Query.exclude(query, :select), select: 1) 567 | else 568 | query 569 | end 570 | else 571 | if opts[:select_star?] do 572 | from(row in Ecto.Query.exclude(query, :select), select: 1) 573 | else 574 | query 575 | end 576 | end 577 | end 578 | 579 | defp limit_from_many(query, _, _, _, opts) do 580 | if opts[:select_star?] do 581 | from(row in Ecto.Query.exclude(query, :select), select: 1) 582 | else 583 | query 584 | end 585 | end 586 | 587 | defp set_has_parent_expr_context(query, relationship) do 588 | has_parent_expr? = 589 | Ash.Actions.Read.Relationships.has_parent_expr?( 590 | %{ 591 | relationship 592 | | filter: query.filter, 593 | sort: query.sort 594 | }, 595 | query.context, 596 | query.domain 597 | ) 598 | 599 | Ash.Query.set_context(query, %{data_layer: %{has_parent_expr?: has_parent_expr?}}) 600 | end 601 | 602 | def set_join_prefix(join_query, query, resource) do 603 | %{join_query | prefix: join_prefix(join_query, query, resource)} 604 | end 605 | 606 | defp join_prefix(base_query, query, resource) do 607 | if Ash.Resource.Info.multitenancy_strategy(resource) == :context do 608 | query.__ash_bindings__.sql_behaviour.schema(resource) || 609 | Map.get(query, :__tenant__) || 610 | Map.get(base_query, :__tenant__) || 611 | base_query.prefix || 612 | query.__ash_bindings__.sql_behaviour.repo(resource, :mutate).config()[ 613 | :default_prefix 614 | ] 615 | else 616 | query.__ash_bindings__.sql_behaviour.schema(resource) || 617 | query.__ash_bindings__.sql_behaviour.repo(resource, :mutate).config()[ 618 | :default_prefix 619 | ] 620 | end 621 | end 622 | 623 | defp can_inner_join?(path, expr) do 624 | expr 625 | |> AshSql.Expr.split_statements(:and) 626 | |> Enum.any?(&known_inner_join_predicate_for_path_in_all_branches?(path, &1)) 627 | end 628 | 629 | defp known_inner_join_predicate_for_path_in_all_branches?(path, expr) do 630 | expr 631 | |> AshSql.Expr.split_statements(:or) 632 | |> case do 633 | [expr] -> 634 | case AshSql.Expr.split_statements(expr, :and) do 635 | [expr] -> 636 | known_predicates_only_containing?(path, expr) 637 | 638 | many -> 639 | Enum.any?(many, &known_inner_join_predicate_for_path_in_all_branches?(path, &1)) 640 | end 641 | 642 | branches -> 643 | Enum.all?(branches, &can_inner_join?(path, &1)) 644 | end 645 | end 646 | 647 | defp known_predicates_only_containing?(path, %Not{expression: expression}) do 648 | known_predicates_only_containing?(path, expression) 649 | end 650 | 651 | defp known_predicates_only_containing?(path, %struct{ 652 | __operator__?: true, 653 | left: left, 654 | right: right 655 | }) 656 | when struct in @known_inner_join_operators do 657 | Enum.any?([left, right], &known_predicates_only_containing?(path, &1)) 658 | end 659 | 660 | defp known_predicates_only_containing?(path, %struct{ 661 | __function__?: true, 662 | arguments: arguments 663 | }) 664 | when struct in @known_inner_join_functions do 665 | Enum.any?(arguments, &known_predicates_only_containing?(path, &1)) 666 | end 667 | 668 | defp known_predicates_only_containing?(path, %Ref{relationship_path: path}) do 669 | true 670 | end 671 | 672 | defp known_predicates_only_containing?(_, _), do: false 673 | 674 | @doc false 675 | def get_binding(resource, candidate_path, %{__ash_bindings__: _} = query, types) do 676 | types = List.wrap(types) 677 | 678 | Enum.find_value(query.__ash_bindings__.bindings, fn 679 | {binding, %{path: path, source: source, type: type}} -> 680 | if type in types && 681 | Ash.Resource.Info.synonymous_relationship_paths?( 682 | resource, 683 | path, 684 | candidate_path, 685 | source 686 | ) do 687 | binding 688 | end 689 | 690 | _ -> 691 | nil 692 | end) 693 | end 694 | 695 | def get_binding(_, _, _, _), do: nil 696 | 697 | defp add_distinct(relationship, _join_type, joined_query) do 698 | # Skip distinct for first relationship in aggregate contexts 699 | skip_for_aggregate_first_rel? = 700 | Map.get(joined_query.__ash_bindings__, :skip_distinct_for_first_rel?, false) || 701 | Map.get( 702 | joined_query.__ash_bindings__.context[:data_layer][:parent_bindings] || %{}, 703 | :skip_distinct_for_first_rel?, 704 | false 705 | ) 706 | 707 | if !joined_query.__ash_bindings__[:added_distinct?] && 708 | !(joined_query.__ash_bindings__.in_group? || 709 | joined_query.__ash_bindings__.context[:data_layer][:in_group?]) && 710 | (relationship.cardinality == :many || Map.get(relationship, :from_many?)) && 711 | !skip_for_aggregate_first_rel? && 712 | !joined_query.distinct do 713 | sort = joined_query.__ash_bindings__[:sort] || [] 714 | 715 | joined_query = 716 | Map.update!(joined_query, :__ash_bindings__, &Map.put(&1, :added_distinct?, true)) 717 | 718 | {joined_query, distinct} = 719 | Enum.reduce(sort, {joined_query, []}, fn 720 | {attribute, direction}, {joined_query, distinct} when is_atom(attribute) -> 721 | {joined_query, [{attribute, direction} | distinct]} 722 | 723 | {%Ash.Query.Calculation{} = calculation, direction}, {joined_query, distinct} -> 724 | resource = joined_query.__ash_bindings__.resource 725 | expression = calculation.module.expression(calculation.opts, calculation.context) 726 | filter = %Ash.Filter{resource: resource, expression: expression} 727 | used_aggregates = Ash.Filter.used_aggregates(expression, []) 728 | {:ok, joined_query} = AshSql.Join.join_all_relationships(joined_query, filter) 729 | 730 | {:ok, joined_query} = 731 | AshSql.Aggregate.add_aggregates( 732 | joined_query, 733 | used_aggregates, 734 | resource, 735 | false, 736 | joined_query.__ash_bindings__.root_binding 737 | ) 738 | 739 | case AshSql.Expr.dynamic_expr(joined_query, expression, joined_query.__ash_bindings__) do 740 | {result, _} when is_atom(result) -> {joined_query, []} 741 | {dynamic_expr, _} -> {joined_query, [{dynamic_expr, direction} | distinct]} 742 | end 743 | 744 | _other, {joined_query, distinct} -> 745 | {joined_query, distinct} 746 | end) 747 | |> then(fn {joined_query, distinct} -> 748 | {joined_query, 749 | Enum.concat( 750 | Enum.reverse(distinct), 751 | Ash.Resource.Info.primary_key(joined_query.__ash_bindings__.resource) 752 | )} 753 | end) 754 | 755 | distinct = distinct |> AshSql.Sort.sanitize_sort() 756 | 757 | if joined_query.__ash_bindings__.sql_behaviour.multicolumn_distinct?() do 758 | from(row in joined_query, 759 | distinct: ^distinct 760 | ) 761 | else 762 | from(row in joined_query, distinct: true) 763 | end 764 | else 765 | joined_query 766 | end 767 | end 768 | 769 | defp join_relationship( 770 | query, 771 | %{manual: {module, opts}} = relationship, 772 | path, 773 | kind, 774 | source, 775 | filter, 776 | sort?, 777 | apply_filter 778 | ) do 779 | full_path = path ++ [relationship.name] 780 | initial_ash_bindings = query.__ash_bindings__ 781 | 782 | binding_data = %{type: kind, path: full_path, source: source} 783 | 784 | query = AshSql.Bindings.add_binding(query, binding_data) 785 | 786 | used_aggregates = Ash.Filter.used_aggregates(filter, full_path) 787 | 788 | with {:ok, relationship_destination} <- 789 | related_subquery(relationship, query, 790 | sort?: sort?, 791 | apply_filter?: apply_filter, 792 | refs_at_path: path 793 | ) do 794 | binding_kinds = 795 | case kind do 796 | :left -> 797 | [:left, :inner] 798 | 799 | :inner -> 800 | [:left, :inner] 801 | 802 | other -> 803 | [other] 804 | end 805 | 806 | current_binding = 807 | Enum.find_value( 808 | initial_ash_bindings.bindings, 809 | initial_ash_bindings.root_binding, 810 | fn {binding, data} -> 811 | if data.type in binding_kinds && data.path == path do 812 | binding 813 | end 814 | end 815 | ) 816 | 817 | case apply(module, query.__ash_bindings__.sql_behaviour.manual_relationship_function(), [ 818 | query, 819 | opts, 820 | current_binding, 821 | initial_ash_bindings.current, 822 | kind, 823 | relationship_destination 824 | ]) do 825 | {:ok, query} -> 826 | AshSql.Aggregate.add_aggregates( 827 | query, 828 | used_aggregates, 829 | relationship.destination, 830 | false, 831 | initial_ash_bindings.current, 832 | {query.__ash_bindings__.resource, full_path} 833 | ) 834 | 835 | {:error, query} -> 836 | {:error, query} 837 | end 838 | end 839 | rescue 840 | e in UndefinedFunctionError -> 841 | if e.function == query.__ash_bindings__.sql_behaviour.manual_relationship_function() do 842 | reraise """ 843 | Cannot join to a manual relationship #{inspect(module)} that does not implement the `#{query.__ash_bindings__.sql_behaviour.manual_relationship_behaviour()}` behaviour. 844 | """, 845 | __STACKTRACE__ 846 | else 847 | reraise e, __STACKTRACE__ 848 | end 849 | end 850 | 851 | defp join_relationship( 852 | query, 853 | %{type: :many_to_many} = relationship, 854 | path, 855 | kind, 856 | source, 857 | filter, 858 | sort?, 859 | apply_filter 860 | ) do 861 | destination_filter = 862 | relationship.destination 863 | |> Ash.Query.do_filter(relationship.filter, parent_stack: [query.__ash_bindings__.resource]) 864 | |> Map.get(:filter) 865 | 866 | parent_expr = parent_expr(destination_filter) 867 | 868 | used_parent_aggregates = Ash.Filter.used_aggregates(parent_expr, []) 869 | 870 | if !Enum.empty?(used_parent_aggregates) || 871 | Ash.Actions.Read.Relationships.has_parent_expr?( 872 | relationship, 873 | query.__ash_bindings__[:context], 874 | query.__ash_bindings__[:domain] 875 | ) do 876 | join_many_to_many_with_parent_expr( 877 | query, 878 | relationship, 879 | path, 880 | kind, 881 | source, 882 | filter, 883 | sort?, 884 | apply_filter, 885 | used_parent_aggregates, 886 | parent_expr 887 | ) 888 | else 889 | join_relationship = 890 | Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) 891 | 892 | join_path = path ++ [join_relationship.name] 893 | 894 | full_path = path ++ [relationship.name] 895 | 896 | initial_ash_bindings = query.__ash_bindings__ 897 | 898 | binding_data = %{type: kind, path: full_path, source: source} 899 | 900 | used_aggregates = Ash.Filter.used_aggregates(filter, full_path) 901 | 902 | query = 903 | query 904 | |> AshSql.Bindings.add_binding(%{ 905 | path: join_path, 906 | type: :left, 907 | source: source 908 | }) 909 | |> AshSql.Bindings.add_binding(binding_data) 910 | 911 | with {:ok, query} <- join_all_relationships(query, parent_expr), 912 | {:ok, relationship_through} <- related_subquery(join_relationship, query), 913 | {:ok, relationship_destination} <- 914 | related_subquery(relationship, query, 915 | sort?: sort?, 916 | apply_filter?: apply_filter, 917 | refs_at_path: path 918 | ) do 919 | relationship_through = set_join_prefix(relationship_through, query, relationship.through) 920 | 921 | relationship_destination = 922 | set_join_prefix(relationship_destination, query, relationship.destination) 923 | 924 | {relationship_destination, dest_acc} = 925 | maybe_apply_filter( 926 | relationship_destination, 927 | query, 928 | query.__ash_bindings__, 929 | apply_filter 930 | ) 931 | 932 | query = 933 | query 934 | |> AshSql.Bindings.merge_expr_accumulator(dest_acc) 935 | 936 | binding_kinds = 937 | case kind do 938 | :left -> 939 | [:left, :inner] 940 | 941 | :inner -> 942 | [:left, :inner] 943 | 944 | other -> 945 | [other] 946 | end 947 | 948 | current_binding = 949 | Enum.find_value( 950 | initial_ash_bindings.bindings, 951 | initial_ash_bindings.root_binding, 952 | fn {binding, data} -> 953 | if data.type in binding_kinds && data.path == path do 954 | binding 955 | end 956 | end 957 | ) 958 | 959 | query = 960 | case kind do 961 | :inner -> 962 | from(_ in query, 963 | join: through in ^relationship_through, 964 | as: ^initial_ash_bindings.current, 965 | on: 966 | field(as(^current_binding), ^relationship.source_attribute) == 967 | field(through, ^relationship.source_attribute_on_join_resource), 968 | join: destination in ^relationship_destination, 969 | as: ^(initial_ash_bindings.current + 1), 970 | on: 971 | field(destination, ^relationship.destination_attribute) == 972 | field(through, ^relationship.destination_attribute_on_join_resource) 973 | ) 974 | 975 | _ -> 976 | from(_ in query, 977 | left_join: through in ^relationship_through, 978 | as: ^initial_ash_bindings.current, 979 | on: 980 | field(as(^current_binding), ^relationship.source_attribute) == 981 | field(through, ^relationship.source_attribute_on_join_resource), 982 | left_join: destination in ^relationship_destination, 983 | as: ^(initial_ash_bindings.current + 1), 984 | on: 985 | field(destination, ^relationship.destination_attribute) == 986 | field(through, ^relationship.destination_attribute_on_join_resource) 987 | ) 988 | end 989 | 990 | AshSql.Aggregate.add_aggregates( 991 | query, 992 | used_aggregates, 993 | relationship.destination, 994 | false, 995 | initial_ash_bindings.current, 996 | {query.__ash_bindings__.resource, full_path} 997 | ) 998 | end 999 | end 1000 | end 1001 | 1002 | defp join_relationship( 1003 | query, 1004 | relationship, 1005 | path, 1006 | kind, 1007 | source, 1008 | filter, 1009 | sort?, 1010 | apply_filter 1011 | ) do 1012 | full_path = path ++ [relationship.name] 1013 | initial_ash_bindings = query.__ash_bindings__ 1014 | 1015 | binding_data = %{type: kind, path: full_path, source: source} 1016 | 1017 | query = AshSql.Bindings.add_binding(query, binding_data) 1018 | 1019 | used_aggregates = Ash.Filter.used_aggregates(filter, full_path) 1020 | 1021 | binding_kinds = 1022 | case kind do 1023 | :left -> 1024 | [:left, :inner] 1025 | 1026 | :inner -> 1027 | [:left, :inner] 1028 | 1029 | other -> 1030 | [other] 1031 | end 1032 | 1033 | current_binding = 1034 | Enum.find_value( 1035 | initial_ash_bindings.bindings, 1036 | initial_ash_bindings.root_binding, 1037 | fn {binding, data} -> 1038 | if data.type in binding_kinds && data.path == path do 1039 | binding 1040 | end 1041 | end 1042 | ) 1043 | 1044 | destination_filter = 1045 | relationship.destination 1046 | |> Ash.Query.do_filter(relationship.filter, parent_stack: [query.__ash_bindings__.resource]) 1047 | |> Map.get(:filter) 1048 | 1049 | parent_expr = parent_expr(destination_filter) 1050 | 1051 | used_parent_aggregates = Ash.Filter.used_aggregates(parent_expr, []) 1052 | require_lateral? = !Enum.empty?(used_parent_aggregates) 1053 | 1054 | with {:ok, query} <- 1055 | join_all_relationships( 1056 | query, 1057 | parent_expr, 1058 | [], 1059 | nil, 1060 | relationship_path_to_relationships(query.__ash_bindings__.resource, path) 1061 | ), 1062 | {:ok, query} <- 1063 | AshSql.Aggregate.add_aggregates( 1064 | query, 1065 | used_parent_aggregates, 1066 | initial_ash_bindings.resource, 1067 | false, 1068 | current_binding 1069 | ) do 1070 | case related_subquery(relationship, query, 1071 | sort?: sort?, 1072 | apply_filter: apply_filter, 1073 | start_bindings_at: 500, 1074 | refs_at_path: path, 1075 | require_lateral?: require_lateral?, 1076 | filter_subquery?: true, 1077 | sort?: Map.get(relationship, :from_many?), 1078 | on_subquery: fn subquery -> 1079 | if !Map.get(relationship, :from_many?) || Map.get(relationship, :no_attributes?) do 1080 | subquery 1081 | else 1082 | source_ref = 1083 | AshSql.Expr.ref_binding( 1084 | %Ref{ 1085 | attribute: 1086 | Ash.Resource.Info.attribute( 1087 | query.__ash_bindings__.resource, 1088 | relationship.source_attribute 1089 | ), 1090 | resource: query.__ash_bindings__.resource, 1091 | relationship_path: path 1092 | }, 1093 | query.__ash_bindings__ 1094 | ) 1095 | 1096 | Ecto.Query.from(destination in subquery, 1097 | where: 1098 | field(parent_as(^source_ref), ^relationship.source_attribute) == 1099 | field(destination, ^relationship.destination_attribute) 1100 | ) 1101 | end 1102 | end, 1103 | on_parent_expr: fn subquery -> 1104 | if Map.get(relationship, :no_attributes?) do 1105 | subquery 1106 | else 1107 | from(row in subquery, 1108 | where: 1109 | field(parent_as(^current_binding), ^relationship.source_attribute) == 1110 | field( 1111 | row, 1112 | ^relationship.destination_attribute 1113 | ) 1114 | ) 1115 | end 1116 | end 1117 | ) do 1118 | {:error, error} -> 1119 | {:error, error} 1120 | 1121 | {:ok, relationship_destination} -> 1122 | query = 1123 | case {kind, Map.get(relationship, :no_attributes?, false), 1124 | require_lateral? || 1125 | relationship_destination.__ash_bindings__.context[:data_layer][ 1126 | :has_parent_expr? 1127 | ] || Map.get(relationship, :from_many?, false)} do 1128 | {:inner, true, false} -> 1129 | from(_ in query, 1130 | join: destination in ^relationship_destination, 1131 | as: ^initial_ash_bindings.current, 1132 | on: true 1133 | ) 1134 | 1135 | {:inner, true, true} -> 1136 | from(_ in query, 1137 | inner_lateral_join: destination in ^relationship_destination, 1138 | as: ^initial_ash_bindings.current, 1139 | on: true 1140 | ) 1141 | 1142 | {:inner, false, false} -> 1143 | from(_ in query, 1144 | join: destination in ^relationship_destination, 1145 | as: ^initial_ash_bindings.current, 1146 | on: 1147 | field(as(^current_binding), ^relationship.source_attribute) == 1148 | field( 1149 | destination, 1150 | ^relationship.destination_attribute 1151 | ) 1152 | ) 1153 | 1154 | {:inner, false, true} -> 1155 | from(_ in query, 1156 | inner_lateral_join: destination in ^relationship_destination, 1157 | as: ^initial_ash_bindings.current, 1158 | on: true 1159 | ) 1160 | 1161 | {:left, true, false} -> 1162 | from(_ in query, 1163 | left_join: destination in ^relationship_destination, 1164 | as: ^initial_ash_bindings.current, 1165 | on: true 1166 | ) 1167 | 1168 | {:left, true, true} -> 1169 | from(_ in query, 1170 | left_lateral_join: destination in ^relationship_destination, 1171 | as: ^initial_ash_bindings.current, 1172 | on: true 1173 | ) 1174 | 1175 | {:left, false, false} -> 1176 | from(_ in query, 1177 | left_join: destination in ^relationship_destination, 1178 | as: ^initial_ash_bindings.current, 1179 | on: 1180 | field(as(^current_binding), ^relationship.source_attribute) == 1181 | field( 1182 | destination, 1183 | ^relationship.destination_attribute 1184 | ) 1185 | ) 1186 | 1187 | {:left, false, true} -> 1188 | from(_ in query, 1189 | left_lateral_join: destination in ^relationship_destination, 1190 | as: ^initial_ash_bindings.current, 1191 | on: true 1192 | ) 1193 | end 1194 | 1195 | AshSql.Aggregate.add_aggregates( 1196 | query, 1197 | used_aggregates, 1198 | relationship.destination, 1199 | false, 1200 | initial_ash_bindings.current, 1201 | {query.__ash_bindings__.resource, full_path} 1202 | ) 1203 | end 1204 | end 1205 | end 1206 | 1207 | defp join_many_to_many_with_parent_expr( 1208 | query, 1209 | relationship, 1210 | path, 1211 | kind, 1212 | source, 1213 | filter, 1214 | sort?, 1215 | apply_filter, 1216 | used_parent_aggregates, 1217 | parent_expr 1218 | ) do 1219 | join_relationship = 1220 | Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) 1221 | 1222 | join_path = path ++ [join_relationship.name] 1223 | 1224 | full_path = path ++ [relationship.name] 1225 | 1226 | initial_ash_bindings = query.__ash_bindings__ 1227 | 1228 | binding_data = %{type: kind, path: full_path, source: source} 1229 | 1230 | used_aggregates = Ash.Filter.used_aggregates(filter, full_path) 1231 | 1232 | binding_kinds = 1233 | case kind do 1234 | :left -> 1235 | [:left, :inner] 1236 | 1237 | :inner -> 1238 | [:left, :inner] 1239 | 1240 | other -> 1241 | [other] 1242 | end 1243 | 1244 | current_binding = 1245 | Enum.find_value( 1246 | initial_ash_bindings.bindings, 1247 | initial_ash_bindings.root_binding, 1248 | fn {binding, data} -> 1249 | if data.type in binding_kinds && data.path == path do 1250 | binding 1251 | end 1252 | end 1253 | ) 1254 | 1255 | query_with_bindings = 1256 | AshSql.Bindings.add_binding(query, %{ 1257 | path: join_path, 1258 | type: :left, 1259 | source: source 1260 | }) 1261 | 1262 | query_with_bindings = 1263 | put_in( 1264 | query_with_bindings.__ash_bindings__[:lateral_join_bindings], 1265 | query.__ash_bindings__.current 1266 | ) 1267 | 1268 | with {:ok, query} <- 1269 | join_all_relationships( 1270 | query, 1271 | parent_expr, 1272 | [], 1273 | nil, 1274 | relationship_path_to_relationships(query.__ash_bindings__.resource, path) 1275 | ), 1276 | {:ok, query} <- 1277 | AshSql.Aggregate.add_aggregates( 1278 | query, 1279 | used_parent_aggregates, 1280 | initial_ash_bindings.resource, 1281 | false, 1282 | current_binding 1283 | ), 1284 | {:ok, relationship_through} <- related_subquery(join_relationship, query), 1285 | {:ok, relationship_destination} <- 1286 | related_subquery(relationship, query_with_bindings, 1287 | sort?: sort?, 1288 | apply_filter?: apply_filter, 1289 | on_subquery: fn subquery -> 1290 | case kind do 1291 | :inner -> 1292 | from(destination in subquery, 1293 | join: through in ^relationship_through, 1294 | as: ^initial_ash_bindings.current, 1295 | on: 1296 | field(through, ^relationship.destination_attribute_on_join_resource) == 1297 | field(destination, ^relationship.destination_attribute), 1298 | on: 1299 | field(parent_as(^current_binding), ^relationship.source_attribute) == 1300 | field(through, ^relationship.source_attribute_on_join_resource) 1301 | ) 1302 | 1303 | _ -> 1304 | from(destination in subquery, 1305 | left_join: through in ^relationship_through, 1306 | as: ^initial_ash_bindings.current, 1307 | on: 1308 | field(through, ^relationship.destination_attribute_on_join_resource) == 1309 | field(destination, ^relationship.destination_attribute), 1310 | on: 1311 | field(parent_as(^current_binding), ^relationship.source_attribute) == 1312 | field(through, ^relationship.source_attribute_on_join_resource) 1313 | ) 1314 | end 1315 | end, 1316 | refs_at_path: path 1317 | ) do 1318 | {relationship_destination, dest_acc} = 1319 | maybe_apply_filter( 1320 | relationship_destination, 1321 | query, 1322 | query.__ash_bindings__, 1323 | apply_filter 1324 | ) 1325 | 1326 | query = 1327 | query 1328 | |> AshSql.Bindings.merge_expr_accumulator(dest_acc) 1329 | 1330 | query = 1331 | case kind do 1332 | :inner -> 1333 | from(row in query, 1334 | inner_lateral_join: destination in ^relationship_destination, 1335 | on: true, 1336 | as: ^initial_ash_bindings.current 1337 | ) 1338 | 1339 | :left -> 1340 | from(row in query, 1341 | left_lateral_join: destination in ^relationship_destination, 1342 | on: true, 1343 | as: ^initial_ash_bindings.current 1344 | ) 1345 | end 1346 | 1347 | query = 1348 | query 1349 | |> AshSql.Bindings.add_binding(binding_data) 1350 | 1351 | AshSql.Aggregate.add_aggregates( 1352 | query, 1353 | used_aggregates, 1354 | relationship.destination, 1355 | false, 1356 | initial_ash_bindings.current, 1357 | {query.__ash_bindings__.resource, full_path} 1358 | ) 1359 | end 1360 | end 1361 | 1362 | @doc false 1363 | def maybe_apply_filter(query, _root_query, _bindings, nil), 1364 | do: {query, %AshSql.Expr.ExprInfo{}} 1365 | 1366 | def maybe_apply_filter(query, root_query, bindings, filter) do 1367 | {dynamic, acc} = AshSql.Expr.dynamic_expr(root_query, filter, bindings, true) 1368 | {from(row in query, where: ^dynamic), acc} 1369 | end 1370 | end 1371 | --------------------------------------------------------------------------------