# SPDX-FileCopyrightText: 2023 ash_sqlite contributors # # SPDX-License-Identifier: MIT defmodule AshSqlite.DataLayer do @index %Spark.Dsl.Entity{ name: :index, describe: """ Add an index to be managed by the migration generator. """, examples: [ "index [\"column\", \"column2\"], unique: true, where: \"thing = TRUE\"" ], target: AshSqlite.CustomIndex, schema: AshSqlite.CustomIndex.schema(), transform: {AshSqlite.CustomIndex, :transform, []}, args: [:fields] } @custom_indexes %Spark.Dsl.Section{ name: :custom_indexes, describe: """ A section for configuring indexes to be created by the migration generator. In general, prefer to use `identities` for simple unique constraints. This is a tool to allow for declaring more complex indexes. """, examples: [ """ custom_indexes do index [:column1, :column2], unique: true, where: "thing = TRUE" end """ ], entities: [ @index ] } @statement %Spark.Dsl.Entity{ name: :statement, describe: """ Add a custom statement for migrations. """, examples: [ """ statement :pgweb_idx do up "CREATE INDEX pgweb_idx ON pgweb USING GIN (to_tsvector('english', title || ' ' || body));" down "DROP INDEX pgweb_idx;" end """ ], target: AshSqlite.Statement, schema: AshSqlite.Statement.schema(), args: [:name] } @custom_statements %Spark.Dsl.Section{ name: :custom_statements, describe: """ A section for configuring custom statements to be added to migrations. Changing custom statements may require manual intervention, because Ash can't determine what order they should run in (i.e if they depend on table structure that you've added, or vice versa). As such, any `down` statements we run for custom statements happen first, and any `up` statements happen last. Additionally, when changing a custom statement, we must make some assumptions, i.e that we should migrate the old structure down using the previously configured `down` and recreate it. This may not be desired, and so what you may end up doing is simply modifying the old migration and deleting whatever was generated by the migration generator. As always: read your migrations after generating them! """, examples: [ """ custom_statements do # the name is used to detect if you remove or modify the statement statement :pgweb_idx do up "CREATE INDEX pgweb_idx ON pgweb USING GIN (to_tsvector('english', title || ' ' || body));" down "DROP INDEX pgweb_idx;" end end """ ], entities: [ @statement ] } @reference %Spark.Dsl.Entity{ name: :reference, describe: """ Configures the reference for a relationship in resource migrations. Keep in mind that multiple relationships can theoretically involve the same destination and foreign keys. In those cases, you only need to configure the `reference` behavior for one of them. Any conflicts will result in an error, across this resource and any other resources that share a table with this one. For this reason, instead of adding a reference configuration for `:nothing`, its best to just leave the configuration out, as that is the default behavior if *no* relationship anywhere has configured the behavior of that reference. """, examples: [ "reference :post, on_delete: :delete, on_update: :update, name: \"comments_to_posts_fkey\"" ], args: [:relationship], target: AshSqlite.Reference, schema: AshSqlite.Reference.schema() } @references %Spark.Dsl.Section{ name: :references, describe: """ A section for configuring the references (foreign keys) in resource migrations. This section is only relevant if you are using the migration generator with this resource. Otherwise, it has no effect. """, examples: [ """ references do reference :post, on_delete: :delete, on_update: :update, name: "comments_to_posts_fkey" end """ ], entities: [@reference], schema: [ polymorphic_on_delete: [ type: {:one_of, [:delete, :nilify, :nothing, :restrict]}, doc: "For polymorphic resources, configures the on_delete behavior of the automatically generated foreign keys to source tables." ], polymorphic_on_update: [ type: {:one_of, [:update, :nilify, :nothing, :restrict]}, doc: "For polymorphic resources, configures the on_update behavior of the automatically generated foreign keys to source tables." ], polymorphic_name: [ type: {:one_of, [:update, :nilify, :nothing, :restrict]}, doc: "For polymorphic resources, configures the on_update behavior of the automatically generated foreign keys to source tables." ] ] } @references %Spark.Dsl.Section{ name: :references, describe: """ A section for configuring the references (foreign keys) in resource migrations. This section is only relevant if you are using the migration generator with this resource. Otherwise, it has no effect. """, examples: [ """ references do reference :post, on_delete: :delete, on_update: :update, name: "comments_to_posts_fkey" end """ ], entities: [@reference], schema: [ polymorphic_on_delete: [ type: {:one_of, [:delete, :nilify, :nothing, :restrict]}, doc: "For polymorphic resources, configures the on_delete behavior of the automatically generated foreign keys to source tables." ], polymorphic_on_update: [ type: {:one_of, [:update, :nilify, :nothing, :restrict]}, doc: "For polymorphic resources, configures the on_update behavior of the automatically generated foreign keys to source tables." ], polymorphic_name: [ type: {:one_of, [:update, :nilify, :nothing, :restrict]}, doc: "For polymorphic resources, configures the on_update behavior of the automatically generated foreign keys to source tables." ] ] } @sqlite %Spark.Dsl.Section{ name: :sqlite, describe: """ Sqlite data layer configuration """, sections: [ @custom_indexes, @custom_statements, @references ], modules: [ :repo ], examples: [ """ sqlite do repo MyApp.Repo table "organizations" end """ ], schema: [ repo: [ type: {:or, [{:behaviour, Ecto.Repo}, {:fun, 2}]}, required: true, doc: "The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more. Can also be a function that takes a resource and a type `:read | :mutate` and returns the repo." ], migrate?: [ type: :boolean, default: true, doc: "Whether or not to include this resource in the generated migrations with `mix ash.generate_migrations`" ], migration_types: [ type: :keyword_list, default: [], doc: "A keyword list of attribute names to the ecto migration type that should be used for that attribute. Only necessary if you need to override the defaults." ], migration_defaults: [ type: :keyword_list, default: [], doc: """ A keyword list of attribute names to the ecto migration default that should be used for that attribute. The string you use will be placed verbatim in the migration. Use fragments like `fragment(\\\\"now()\\\\")`, or for `nil`, use `\\\\"nil\\\\"`. """ ], base_filter_sql: [ type: :string, doc: "A raw sql version of the base_filter, e.g `representative = true`. Required if trying to create a unique constraint on a resource with a base_filter" ], skip_unique_indexes: [ type: {:wrap_list, :atom}, default: false, doc: "Skip generating unique indexes when generating migrations" ], unique_index_names: [ type: {:list, {:or, [{:tuple, [{:list, :atom}, :string]}, {:tuple, [{:list, :atom}, :string, :string]}]}}, default: [], doc: """ A list of unique index names that could raise errors that are not configured in identities, or an mfa to a function that takes a changeset and returns the list. In the format `{[:affected, :keys], "name_of_constraint"}` or `{[:affected, :keys], "name_of_constraint", "custom error message"}` """ ], exclusion_constraint_names: [ type: :any, default: [], doc: """ A list of exclusion constraint names that could raise errors. Must be in the format `{:affected_key, "name_of_constraint"}` or `{:affected_key, "name_of_constraint", "custom error message"}` """ ], identity_index_names: [ type: :any, default: [], doc: """ A keyword list of identity names to the unique index name that they should use when being managed by the migration generator. """ ], foreign_key_names: [ type: {:list, {:or, [{:tuple, [:atom, :string]}, {:tuple, [:string, :string]}]}}, default: [], doc: """ A list of foreign keys that could raise errors, or an mfa to a function that takes a changeset and returns a list. In the format: `{:key, "name_of_constraint"}` or `{:key, "name_of_constraint", "custom error message"}` """ ], migration_ignore_attributes: [ type: {:list, :atom}, default: [], doc: """ A list of attributes that will be ignored when generating migrations. """ ], table: [ type: :string, doc: """ The table to store and read the resource from. If this is changed, the migration generator will not remove the old table. """ ], polymorphic?: [ type: :boolean, default: false, doc: """ Declares this resource as polymorphic. See the [polymorphic resources guide](/documentation/topics/resources/polymorphic-resources.md) for more. """ ], strict?: [ type: :boolean, default: false, doc: """ Whether the migration generator should create a [strict table](https://www.sqlite.org/stricttables.html), which enforces types more strictly. """ ] ] } @behaviour Ash.DataLayer @sections [@sqlite] @moduledoc """ A sqlite data layer that leverages Ecto's sqlite capabilities. """ use Spark.Dsl.Extension, sections: @sections, transformers: [ AshSqlite.Transformers.ValidateReferences, AshSqlite.Transformers.VerifyRepo, AshSqlite.Transformers.EnsureTableOrPolymorphic ] def migrate(args) do # TODO: take args that we care about Mix.Task.run("ash_sqlite.migrate", args) end def rollback(args) do {opts, _, _} = OptionParser.parse(args, switches: [ repo: :string ], aliases: [r: :repo] ) repos = AshSqlite.Mix.Helpers.repos!(opts, args) show_for_repo? = Enum.count_until(repos, 2) == 2 for repo <- repos do for_repo = if show_for_repo? do " for repo #{inspect(repo)}" else "" end migrations_path = AshSqlite.Mix.Helpers.migrations_path([], repo) files = migrations_path |> Path.join("**/*.exs") |> Path.wildcard() |> Enum.sort() |> Enum.reverse() |> Enum.take(20) |> Enum.map(&String.trim_leading(&1, migrations_path)) |> Enum.map(&String.trim_leading(&1, "/")) indexed = files |> Enum.with_index() |> Enum.map(fn {file, index} -> "#{index + 1}: #{file}" end) to = Mix.shell().prompt( """ How many migrations should be rolled back#{for_repo}? (default: 0) Last 20 migration names, with the input you must provide to rollback up to *and including* that migration: #{Enum.join(indexed, "\n")} Rollback to: """ |> String.trim_trailing() ) |> String.trim() |> case do "" -> nil "0" -> nil n -> try do files |> Enum.at(String.to_integer(n) - 1) rescue _ -> # credo:disable-for-next-line raise "Required an integer value, got: #{n}" end |> String.split("_", parts: 2) |> Enum.at(0) |> String.to_integer() end if to do Mix.Task.run("ash_sqlite.rollback", args ++ ["-r", inspect(repo), "--to", to_string(to)]) Mix.Task.reenable("ash_sqlite.rollback") end end end if Code.ensure_loaded?(Igniter) do def install(igniter, module, Ash.Resource, _path, argv) do table_name = module |> Module.split() |> List.last() |> Macro.underscore() |> Igniter.Inflex.pluralize() {options, _, _} = OptionParser.parse(argv, switches: [repo: :string]) repo = case options[:repo] do nil -> Igniter.Project.Module.module_name(igniter, "Repo") repo -> Igniter.Project.Module.parse(repo) end igniter |> Spark.Igniter.set_option(module, [:sqlite, :table], table_name) |> Spark.Igniter.set_option(module, [:sqlite, :repo], repo) end def install(igniter, _, _, _), do: igniter end def codegen(args) do Mix.Task.reenable("ash_sqlite.generate_migrations") Mix.Task.run("ash_sqlite.generate_migrations", args) end def setup(args) do # TODO: take args that we care about Mix.Task.run("ash_sqlite.create", args) Mix.Task.run("ash_sqlite.migrate", args) end def tear_down(args) do # TODO: take args that we care about Mix.Task.run("ash_sqlite.drop", args) end import Ecto.Query, only: [from: 2] @impl true def can?(_, :async_engine), do: false def can?(_, :bulk_create), do: true def can?(_, :update_query), do: true def can?(_, :destroy_query), do: true def can?(_, {:lock, _}), do: false def can?(_, :transact), do: false def can?(_, :composite_primary_key), do: true def can?(_, {:atomic, :update}), do: true def can?(_, {:atomic, :upsert}), do: true def can?(_, {:atomic, :create}), do: true def can?(_, :upsert), do: true def can?(_, :changeset_filter), do: true def can?(resource, {:join, other_resource}) do data_layer = Ash.DataLayer.data_layer(resource) other_data_layer = Ash.DataLayer.data_layer(other_resource) data_layer == other_data_layer and AshSqlite.DataLayer.Info.repo(resource) == AshSqlite.DataLayer.Info.repo(other_resource) end def can?(_resource, {:lateral_join, _}) do false end def can?(_, :boolean_filter), do: true def can?(_, {:aggregate, _type}), do: false def can?(_, :aggregate_filter), do: false def can?(_, :aggregate_sort), do: false def can?(_, :expression_calculation), do: true def can?(_, :expression_calculation_sort), do: true def can?(_, :create), do: true def can?(_, :select), do: true def can?(_, :read), do: true def can?(resource, action) when action in ~w[update destroy]a do resource |> Ash.Resource.Info.primary_key() |> Enum.any?() end def can?(_, :filter), do: true def can?(_, :limit), do: true def can?(_, :offset), do: true def can?(_, :multitenancy), do: false def can?(_, {:filter_relationship, %{manual: {module, _}}}) do Spark.implements_behaviour?(module, AshSqlite.ManualRelationship) end def can?(_, {:filter_relationship, _}), do: true def can?(_, {:aggregate_relationship, _}), do: false def can?(_, :timeout), do: true def can?(_, {:filter_expr, %Ash.Query.Function.StringJoin{}}), do: false def can?(_, {:filter_expr, _}), do: true def can?(_, :nested_expressions), do: true def can?(_, {:query_aggregate, kind}) when kind in [:count, :first, :sum, :max, :min, :avg, :exists], do: true def can?(_, :sort), do: true def can?(_, :distinct_sort), do: false def can?(_, :distinct), do: false def can?(_, {:sort, _}), do: true def can?(_, _), do: false @impl true def limit(query, nil, _), do: {:ok, query} def limit(query, limit, _resource) do {:ok, from(row in query, limit: ^limit)} end @impl true def source(resource) do AshSqlite.DataLayer.Info.table(resource) || "" end @impl true def set_context(resource, data_layer_query, context) do start_bindings = context[:data_layer][:start_bindings_at] || 0 data_layer_query = from(row in data_layer_query, as: ^start_bindings) data_layer_query = if context[:data_layer][:table] do %{ data_layer_query | from: %{data_layer_query.from | source: {context[:data_layer][:table], resource}} } else data_layer_query end {:ok, AshSql.Bindings.default_bindings( data_layer_query, resource, AshSqlite.SqlImplementation, context )} end @impl true def offset(query, nil, _), do: query def offset(%{offset: old_offset} = query, 0, _resource) when old_offset in [0, nil] do {:ok, query} end def offset(query, offset, _resource) do {:ok, from(row in query, offset: ^offset)} end @impl true def run_aggregate_query(query, aggregates, resource) do AshSql.AggregateQuery.run_aggregate_query( query, aggregates, resource, AshSqlite.SqlImplementation ) end @impl true def run_query(query, resource) do with_sort_applied = if query.__ash_bindings__[:sort_applied?] do {:ok, query} else AshSql.Sort.apply_sort(query, query.__ash_bindings__[:sort], resource) end case with_sort_applied do {:error, error} -> {:error, error} {:ok, query} -> query = if query.__ash_bindings__[:__order__?] && query.windows[:order] do order_by = %{query.windows[:order] | expr: query.windows[:order].expr[:order_by]} %{ query | windows: Keyword.delete(query.windows, :order), order_bys: [order_by] } else %{query | windows: Keyword.delete(query.windows, :order)} end if AshSqlite.DataLayer.Info.polymorphic?(resource) && no_table?(query) do raise_table_error!(resource, :read) else repo = dynamic_repo(resource, query) opts = AshSql.repo_opts(repo, AshSqlite.SqlImplementation, nil, nil, resource) {:ok, repo.all( query, opts )} end end rescue e -> handle_raised_error(e, __STACKTRACE__, query, resource) end defp no_table?(%{from: %{source: {"", _}}}), do: true defp no_table?(_), do: false @impl true def functions(_resource) do [ AshSqlite.Functions.Like, AshSqlite.Functions.ILike ] end @impl true def resource_to_query(resource, _) do from(row in {AshSqlite.DataLayer.Info.table(resource) || "", resource}, []) end @impl true def bulk_create(resource, stream, options) do # Cell-wise default values are not supported on INSERT statements by SQLite # This requires that we group changesets by what attributes are changing # And *omit* any defaults instead of using something like `(1, 2, DEFAULT)` # like we could with postgres stream |> Enum.group_by(&Map.keys(&1.attributes)) |> Enum.reduce_while({:ok, []}, fn {_, changesets}, {:ok, acc} -> repo = AshSql.dynamic_repo(resource, AshSqlite.SqlImplementation, Enum.at(changesets, 0)) opts = AshSql.repo_opts(repo, AshSqlite.SqlImplementation, nil, options[:tenant], resource) opts = if options.return_records? do returning = case options[:action_select] do nil -> true [] -> Ash.Resource.Info.primary_key(resource) fields -> fields end Keyword.put(opts, :returning, returning) else opts end opts = if options[:upsert?] do # Ash groups changesets by atomics before dispatching them to the data layer # this means that all changesets have the same atomics %{atomics: atomics, filter: filter} = Enum.at(changesets, 0) query = from(row in resource, as: ^0) query = query |> AshSql.Bindings.default_bindings(resource, AshSqlite.SqlImplementation) upsert_set = upsert_set( resource, changesets, options[:upsert_keys] || Ash.Resource.Info.primary_key(resource), options ) on_conflict = case AshSql.Atomics.query_with_atomics( resource, query, filter, atomics, %{}, upsert_set ) do {:empty, _query} -> raise "Cannot upsert with no fields to specify in the upsert statement. This can only happen on resources without a primary key." {:ok, query} -> query {:error, error} -> raise Ash.Error.to_ash_error(error) end opts |> Keyword.put(:on_conflict, on_conflict) |> Keyword.put( :conflict_target, conflict_target( resource, options[:upsert_keys] || Ash.Resource.Info.primary_key(resource) ) ) else opts end create_atomics = Map.get(Enum.at(changesets, 0), :create_atomics, []) atomic_insert_values = if create_atomics != [] do query = from(row in resource, as: ^0) query = query |> AshSql.Bindings.default_bindings(resource, AshSqlite.SqlImplementation) case AshSql.Atomics.atomics_to_insert_values(resource, query, create_atomics) do {:ok, values} -> values {:error, error} -> raise Ash.Error.to_ash_error(error) end else %{} end ecto_changesets = Enum.map(changesets, fn cs -> Map.merge(cs.attributes, atomic_insert_values) end) source = if table = Enum.at(changesets, 0).context[:data_layer][:table] do {table, resource} else resource end source |> repo.insert_all(ecto_changesets, opts) |> case do {_, nil} -> {:cont, {:ok, acc}} {_, results} -> if options[:single?] do {:cont, {:ok, acc ++ results}} else {:cont, {:ok, acc ++ Enum.zip_with(results, changesets, fn result, changeset -> Ash.Resource.put_metadata( result, :bulk_action_ref, changeset.context.bulk_create.ref ) end)}} end end end) |> case do {:ok, records} -> if options[:return_records?] do {:ok, records} else :ok end {:error, error} -> {:error, error} end rescue e -> changeset = Ash.Changeset.new(resource) handle_raised_error( e, __STACKTRACE__, {:bulk_create, ecto_changeset(changeset.data, changeset, :create, false)}, resource ) end defp upsert_set(resource, changesets, keys, options) do attributes_changing_anywhere = changesets |> Enum.flat_map(&Map.keys(&1.attributes)) |> Enum.uniq() update_defaults = update_defaults(resource) # We can't reference EXCLUDED if at least one of the changesets in the stream is not # changing the value (and we wouldn't want to even if we could as it would be unnecessary) upsert_fields = (options[:upsert_fields] || []) |> Enum.filter(&(&1 in attributes_changing_anywhere)) fields_to_upsert = upsert_fields -- (Keyword.keys(Enum.at(changesets, 0).atomics) -- keys) fields_to_upsert = case fields_to_upsert do [] -> keys fields_to_upsert -> # Include fields with update_defaults (e.g. update_timestamp) # even if they aren't in the changeset attributes or upsert_fields. # These fields should always be refreshed when an upsert modifies fields. # Can be disabled via touch_update_defaults?: false in the changeset # context (either in [:private] or [:data_layer]) or via options map touch_update_defaults? = Map.get(options, :touch_update_defaults?, true) && Enum.at(changesets, 0).context[:private][:touch_update_defaults?] != false && Enum.at(changesets, 0).context[:data_layer][:touch_update_defaults?] != false if touch_update_defaults? do update_default_fields = update_defaults |> Keyword.keys() |> Enum.reject(&(&1 in fields_to_upsert or &1 in keys)) fields_to_upsert ++ update_default_fields else fields_to_upsert end end fields_to_upsert |> Enum.uniq() |> Enum.map(fn upsert_field -> # for safety, we check once more at the end that all values in # upsert_fields are names of attributes. This is because # below we use `literal/1` to bring them into the query if is_nil(resource.__schema__(:type, upsert_field)) do raise "Only attribute names can be used in upsert_fields" end case Keyword.fetch(update_defaults, upsert_field) do {:ok, default} -> if upsert_field in upsert_fields do {upsert_field, Ecto.Query.dynamic( [], fragment( "COALESCE(EXCLUDED.?, ?)", identifier(^to_string(get_source_for_upsert_field(upsert_field, resource))), ^default ) )} else {upsert_field, default} end :error -> {upsert_field, Ecto.Query.dynamic( [], fragment( "EXCLUDED.?", identifier(^to_string(get_source_for_upsert_field(upsert_field, resource))) ) )} end end) end @doc false def get_source_for_upsert_field(field, resource) do case Ash.Resource.Info.attribute(resource, field) do %{source: source} when not is_nil(source) -> source _ -> field end end @impl true def create(resource, changeset) do changeset = %{ changeset | data: Map.update!( changeset.data, :__meta__, &Map.put(&1, :source, table(resource, changeset)) ) } case bulk_create(resource, [changeset], %{ single?: true, tenant: changeset.tenant, return_records?: true }) do {:ok, [result]} -> {:ok, result} {:error, error} -> {:error, error} end end defp ecto_changeset(record, changeset, type, table_error?) do filters = if changeset.action_type == :create do %{} else changeset.resource |> Ash.Resource.Info.primary_key() |> Enum.reduce(%{}, fn key, filters -> Map.put(filters, key, Map.get(record, key)) end) end attributes = changeset.resource |> Ash.Resource.Info.attributes() |> Enum.map(& &1.name) attributes_to_change = Enum.reject(attributes, fn attribute -> Keyword.has_key?(changeset.atomics, attribute) end) ecto_changeset = record |> to_ecto() |> set_table(changeset, type, table_error?) |> Ecto.Changeset.change(Map.take(changeset.attributes, attributes_to_change)) |> Map.update!(:filters, &Map.merge(&1, filters)) |> add_configured_foreign_key_constraints(record.__struct__) |> add_unique_indexes(record.__struct__, changeset) |> add_exclusion_constraints(record.__struct__) case type do :create -> ecto_changeset |> add_my_foreign_key_constraints(record.__struct__) type when type in [:upsert, :update] -> ecto_changeset |> add_my_foreign_key_constraints(record.__struct__) |> add_related_foreign_key_constraints(record.__struct__) :delete -> ecto_changeset |> add_related_foreign_key_constraints(record.__struct__) end end defp handle_raised_error( %Ecto.StaleEntryError{changeset: %{data: %resource{}, filters: filters}}, stacktrace, context, resource ) do handle_raised_error( Ash.Error.Changes.StaleRecord.exception(resource: resource, filters: filters), stacktrace, context, resource ) end defp handle_raised_error(%Ecto.Query.CastError{} = e, stacktrace, context, resource) do handle_raised_error( Ash.Error.Query.InvalidFilterValue.exception(value: e.value, context: context), stacktrace, context, resource ) end defp handle_raised_error( %Exqlite.Error{ message: "FOREIGN KEY constraint failed" }, stacktrace, context, resource ) do handle_raised_error( Ash.Error.Changes.InvalidChanges.exception( fields: Ash.Resource.Info.primary_key(resource), message: "referenced something that does not exist" ), stacktrace, context, resource ) end defp handle_raised_error( %Exqlite.Error{ message: "UNIQUE constraint failed: " <> fields }, _stacktrace, _context, resource ) do names = fields |> String.split(", ") |> Enum.map(fn field -> field |> String.split(".", trim: true) |> Enum.drop(1) |> Enum.at(0) end) |> Enum.map(fn field -> Ash.Resource.Info.attribute(resource, field) end) |> Enum.reject(&is_nil/1) |> Enum.map(fn %{name: name} -> name end) message = find_constraint_message(resource, names) {:error, names |> Enum.map(fn name -> Ash.Error.Changes.InvalidAttribute.exception( field: name, message: message ) end)} end defp handle_raised_error(error, stacktrace, _ecto_changeset, _resource) do {:error, Ash.Error.to_ash_error(error, stacktrace)} end defp find_constraint_message(resource, names) do find_custom_index_message(resource, names) || find_identity_message(resource, names) || "has already been taken" end defp find_custom_index_message(resource, names) do resource |> AshSqlite.DataLayer.Info.custom_indexes() |> Enum.find(fn %{fields: fields} -> fields |> Enum.map(&to_string/1) |> Enum.sort() == names |> Enum.map(&to_string/1) |> Enum.sort() end) |> case do %{message: message} when is_binary(message) -> message _ -> nil end end defp find_identity_message(resource, names) do resource |> Ash.Resource.Info.identities() |> Enum.find(fn %{keys: fields} -> fields |> Enum.map(&to_string/1) |> Enum.sort() == names |> Enum.map(&to_string/1) |> Enum.sort() end) |> case do %{message: message} when is_binary(message) -> message _ -> nil end end defp set_table(record, changeset, operation, table_error?) do if AshSqlite.DataLayer.Info.polymorphic?(record.__struct__) do table = changeset.context[:data_layer][:table] || AshSqlite.DataLayer.Info.table(record.__struct__) if table do Ecto.put_meta(record, source: table) else if table_error? do raise_table_error!(changeset.resource, operation) else record end end else record end end def from_ecto({:ok, result}), do: {:ok, from_ecto(result)} def from_ecto({:error, _} = other), do: other def from_ecto(nil), do: nil def from_ecto(value) when is_list(value) do Enum.map(value, &from_ecto/1) end def from_ecto(%resource{} = record) do if Spark.Dsl.is?(resource, Ash.Resource) do empty = struct(resource) resource |> Ash.Resource.Info.relationships() |> Enum.reduce(record, fn relationship, record -> case Map.get(record, relationship.name) do %Ecto.Association.NotLoaded{} -> Map.put(record, relationship.name, Map.get(empty, relationship.name)) value -> Map.put(record, relationship.name, from_ecto(value)) end end) else record end end def from_ecto(other), do: other def to_ecto(nil), do: nil def to_ecto(value) when is_list(value) do Enum.map(value, &to_ecto/1) end def to_ecto(%resource{} = record) do if Spark.Dsl.is?(resource, Ash.Resource) do resource |> Ash.Resource.Info.relationships() |> Enum.reduce(record, fn relationship, record -> value = case Map.get(record, relationship.name) do %Ash.NotLoaded{} -> %Ecto.Association.NotLoaded{ __field__: relationship.name, __cardinality__: relationship.cardinality } value -> to_ecto(value) end Map.put(record, relationship.name, value) end) else record end end def to_ecto(other), do: other defp add_exclusion_constraints(changeset, resource) do resource |> AshSqlite.DataLayer.Info.exclusion_constraint_names() |> Enum.reduce(changeset, fn constraint, changeset -> case constraint do {key, name} -> Ecto.Changeset.exclusion_constraint(changeset, key, name: name) {key, name, message} -> Ecto.Changeset.exclusion_constraint(changeset, key, name: name, message: message) end end) end defp add_related_foreign_key_constraints(changeset, resource) do # TODO: this doesn't guarantee us to get all of them, because if something is related to this # schema and there is no back-relation, then this won't catch it's foreign key constraints resource |> Ash.Resource.Info.relationships() |> Enum.map(& &1.destination) |> Enum.uniq() |> Enum.flat_map(fn related -> related |> Ash.Resource.Info.relationships() |> Enum.filter(&(&1.destination == resource)) |> Enum.map(&Map.take(&1, [:source, :source_attribute, :destination_attribute, :name])) end) |> Enum.reduce(changeset, fn %{ source: source, source_attribute: source_attribute, destination_attribute: destination_attribute, name: relationship_name }, changeset -> case AshSqlite.DataLayer.Info.reference(resource, relationship_name) do %{name: name} when not is_nil(name) -> Ecto.Changeset.foreign_key_constraint(changeset, destination_attribute, name: name, message: "would leave records behind" ) _ -> Ecto.Changeset.foreign_key_constraint(changeset, destination_attribute, name: "#{AshSqlite.DataLayer.Info.table(source)}_#{source_attribute}_fkey", message: "would leave records behind" ) end end) end defp add_my_foreign_key_constraints(changeset, resource) do resource |> Ash.Resource.Info.relationships() |> Enum.reduce(changeset, &Ecto.Changeset.foreign_key_constraint(&2, &1.source_attribute)) end defp add_configured_foreign_key_constraints(changeset, resource) do resource |> AshSqlite.DataLayer.Info.foreign_key_names() |> case do {m, f, a} -> List.wrap(apply(m, f, [changeset | a])) value -> List.wrap(value) end |> Enum.reduce(changeset, fn {key, name}, changeset -> Ecto.Changeset.foreign_key_constraint(changeset, key, name: name) {key, name, message}, changeset -> Ecto.Changeset.foreign_key_constraint(changeset, key, name: name, message: message) end) end defp add_unique_indexes(changeset, resource, ash_changeset) do changeset = resource |> Ash.Resource.Info.identities() |> Enum.reduce(changeset, fn identity, changeset -> name = AshSqlite.DataLayer.Info.identity_index_names(resource)[identity.name] || "#{table(resource, ash_changeset)}_#{identity.name}_index" opts = if Map.get(identity, :message) do [name: name, message: identity.message] else [name: name] end Ecto.Changeset.unique_constraint(changeset, identity.keys, opts) end) changeset = resource |> AshSqlite.DataLayer.Info.custom_indexes() |> Enum.reduce(changeset, fn index, changeset -> opts = if index.message do [name: index.name, message: index.message] else [name: index.name] end Ecto.Changeset.unique_constraint(changeset, index.fields, opts) end) names = resource |> AshSqlite.DataLayer.Info.unique_index_names() |> case do {m, f, a} -> List.wrap(apply(m, f, [changeset | a])) value -> List.wrap(value) end names = case Ash.Resource.Info.primary_key(resource) do [] -> names fields -> if table = table(resource, ash_changeset) do [{fields, table <> "_pkey"} | names] else [] end end Enum.reduce(names, changeset, fn {keys, name}, changeset -> Ecto.Changeset.unique_constraint(changeset, List.wrap(keys), name: name) {keys, name, message}, changeset -> Ecto.Changeset.unique_constraint(changeset, List.wrap(keys), name: name, message: message) end) end @impl true def upsert(resource, changeset, keys \\ nil) do keys = keys || Ash.Resource.Info.primary_key(keys) touch_update_defaults? = changeset.context[:private][:touch_update_defaults?] != false update_defaults = update_defaults(resource) explicitly_changing_attributes = changeset.attributes |> Map.keys() |> then(fn attrs -> if touch_update_defaults? do Enum.concat(attrs, Keyword.keys(update_defaults)) else attrs end end) |> Kernel.--(Map.get(changeset, :defaults, [])) |> Kernel.--(keys) upsert_fields = changeset.context[:private][:upsert_fields] || explicitly_changing_attributes case bulk_create(resource, [changeset], %{ single?: true, upsert?: true, tenant: changeset.tenant, upsert_keys: keys, upsert_fields: upsert_fields, touch_update_defaults?: touch_update_defaults?, return_records?: true }) do {:ok, []} -> key_filters = Enum.map(keys, fn key -> {key, Ash.Changeset.get_attribute(changeset, key) || Map.get(changeset.params, key) || Map.get(changeset.params, to_string(key))} end) ash_query = resource |> Ash.Query.do_filter(and: [key_filters]) |> Ash.Query.set_tenant(changeset.tenant) {:ok, {:upsert_skipped, ash_query, fn -> with {:ok, ecto_query} <- Ash.Query.data_layer_query(ash_query), {:ok, [result]} <- run_query(ecto_query, resource) do {:ok, Ash.Resource.put_metadata(result, :upsert_skipped, true)} end end}} {:ok, [result]} -> {:ok, result} {:error, error} -> {:error, error} end end defp conflict_target(resource, keys) do if Ash.Resource.Info.base_filter(resource) do base_filter_sql = AshSqlite.DataLayer.Info.base_filter_sql(resource) || raise """ Cannot use upserts with resources that have a base_filter without also adding `base_filter_sql` in the sqlite section. """ sources = Enum.map(keys, fn key -> ~s("#{Ash.Resource.Info.attribute(resource, key).source || key}") end) {:unsafe_fragment, "(" <> Enum.join(sources, ", ") <> ") WHERE (#{base_filter_sql})"} else keys end end defp update_defaults(resource) do attributes = resource |> Ash.Resource.Info.attributes() |> Enum.reject(&is_nil(&1.update_default)) attributes |> static_defaults() |> Enum.concat(lazy_matching_defaults(attributes)) |> Enum.concat(lazy_non_matching_defaults(attributes)) end defp static_defaults(attributes) do attributes |> Enum.reject(&get_default_fun(&1)) |> Enum.map(&{&1.name, &1.update_default}) end defp lazy_non_matching_defaults(attributes) do attributes |> Enum.filter(&(!&1.match_other_defaults? && get_default_fun(&1))) |> Enum.map(fn attribute -> default_value = case attribute.update_default do function when is_function(function) -> function.() {m, f, a} when is_atom(m) and is_atom(f) and is_list(a) -> apply(m, f, a) end {:ok, default_value} = Ash.Type.cast_input(attribute.type, default_value, attribute.constraints) {attribute.name, default_value} end) end defp lazy_matching_defaults(attributes) do attributes |> Enum.filter(&(&1.match_other_defaults? && get_default_fun(&1))) |> Enum.group_by(&{&1.update_default, &1.type, &1.constraints}) |> Enum.flat_map(fn {{default_fun, type, constraints}, attributes} -> default_value = case default_fun do function when is_function(function) -> function.() {m, f, a} when is_atom(m) and is_atom(f) and is_list(a) -> apply(m, f, a) end {:ok, default_value} = Ash.Type.cast_input(type, default_value, constraints) Enum.map(attributes, &{&1.name, default_value}) end) end defp get_default_fun(attribute) do if is_function(attribute.update_default) or match?({_, _, _}, attribute.update_default) do attribute.update_default end end @impl true def update(resource, changeset) do source = resolve_source(resource, changeset) query = from(row in source, as: ^0) |> AshSql.Bindings.default_bindings( resource, AshSqlite.SqlImplementation, changeset.context ) |> pkey_filter(changeset.data) |> then(fn query -> Map.put( query, :__ash_bindings__, Map.put_new(query.__ash_bindings__, :tenant, changeset.tenant) ) end) changeset = Ash.Changeset.set_context(changeset, %{ data_layer: %{ use_atomic_update_data?: true } }) case update_query(query, changeset, resource, %{ return_records?: true, action_select: changeset.action_select, calculations: [] }) do {:ok, []} -> {:error, Ash.Error.Changes.StaleRecord.exception( resource: resource, filter: changeset.filter )} {:ok, [record]} -> {:ok, record} {:error, error} -> {:error, error} end end defp pkey_filter(query, %resource{} = record) do pkey = record |> Map.take(Ash.Resource.Info.primary_key(resource)) |> Map.to_list() Ecto.Query.where(query, ^pkey) end @impl true def destroy(resource, %{data: record} = changeset) do source = resolve_source(resource, changeset) query = from(row in source, as: ^0) |> AshSql.Bindings.default_bindings( resource, AshSqlite.SqlImplementation, changeset.context ) |> pkey_filter(record) repo = AshSql.dynamic_repo(resource, AshSqlite.SqlImplementation, changeset) with {:ok, query} <- filter(query, changeset.filter, resource) do ecto_changeset = case changeset.data do %Ash.Changeset.OriginalDataNotAvailable{} -> changeset.resource.__struct__() data -> data end |> Map.update!(:__meta__, &Map.put(&1, :source, table(resource, changeset))) |> ecto_changeset(changeset, :delete, true) case bulk_updatable_query( query, resource, [], [], changeset.context, :destroy ) do {:error, error} -> {:error, error} {:ok, query} -> try do repo_opts = AshSql.repo_opts( repo, AshSqlite.SqlImplementation, changeset.timeout, changeset.tenant, changeset.resource ) query = Ecto.Query.exclude(query, :select) repo.delete_all( query, repo_opts ) |> case do {0, _} -> {:error, Ash.Error.Changes.StaleRecord.exception( resource: resource, filter: changeset.filter )} _ -> :ok end rescue e -> handle_raised_error(e, __STACKTRACE__, ecto_changeset, resource) end end end end @impl true def update_query(query, changeset, resource, options) do repo = AshSql.dynamic_repo(resource, AshSqlite.SqlImplementation, changeset) ecto_changeset = case changeset.data do %Ash.Changeset.OriginalDataNotAvailable{} -> changeset.resource.__struct__() data -> data end |> Map.update!(:__meta__, &Map.put(&1, :source, table(resource, changeset))) |> ecto_changeset(changeset, :update, true) case bulk_updatable_query( query, resource, changeset.atomics, options[:calculations] || [], changeset.context ) do {:error, error} -> {:error, error} {:ok, query} -> try do repo_opts = AshSql.repo_opts( repo, AshSqlite.SqlImplementation, changeset.timeout, changeset.tenant, changeset.resource ) case AshSql.Atomics.query_with_atomics( resource, query, changeset.filter, changeset.atomics, ecto_changeset.changes, [] ) do {:empty, query} -> if options[:return_records?] do if changeset.context[:data_layer][:use_atomic_update_data?] do case query.__ash_bindings__ do %{expression_accumulator: %AshSql.Expr.ExprInfo{has_error?: true}} -> # if the query could produce an error # we must run it even if we will just be returning the original data. repo.all(query, repo_opts) _ -> :ok end {:ok, [changeset.data]} else {:ok, repo.all(query, repo_opts)} end else :ok end {:ok, query} -> query = if options[:return_records?] do {:ok, query} = if options[:action_select] do query |> Ecto.Query.exclude(:select) |> Ecto.Query.select([row], struct(row, ^options[:action_select])) |> add_calculations(options[:calculations] || [], resource) else query |> Ecto.Query.exclude(:select) |> Ecto.Query.select([row], row) |> add_calculations(options[:calculations] || [], resource) end query else Ecto.Query.exclude(query, :select) end {_, results} = repo.update_all( Map.delete(query, :__ash_bindings__), [], repo_opts ) if options[:return_records?] do results = AshSql.Query.remap_mapped_fields(results, query) if changeset.context[:data_layer][:use_atomic_update_data?] && Enum.count_until(results, 2) == 1 do modifying = Map.keys(changeset.attributes) ++ Keyword.keys(changeset.atomics) ++ Ash.Resource.Info.primary_key(resource) result = hd(results) Map.merge(changeset.data, Map.take(result, modifying)) |> Map.update!(:aggregates, &Map.merge(&1, result.aggregates)) |> Map.update!(:calculations, &Map.merge(&1, result.calculations)) |> then(&{:ok, [&1]}) else {:ok, results} end else :ok end {:error, error} -> {:error, error} end rescue e -> handle_raised_error(e, __STACKTRACE__, ecto_changeset, resource) end end end @impl true def destroy_query(query, changeset, resource, options) do repo = AshSql.dynamic_repo(resource, AshSqlite.SqlImplementation, changeset) ecto_changeset = case changeset.data do %Ash.Changeset.OriginalDataNotAvailable{} -> changeset.resource.__struct__() data -> data end |> Map.update!(:__meta__, &Map.put(&1, :source, table(resource, changeset))) |> ecto_changeset(changeset, :delete, true) case bulk_updatable_query( query, resource, [], options[:calculations] || [], changeset.context, :destroy ) do {:error, error} -> {:error, error} {:ok, query} -> try do repo_opts = AshSql.repo_opts( repo, AshSqlite.SqlImplementation, changeset.timeout, changeset.tenant, changeset.resource ) query = if options[:return_records?] do {:ok, query} = case options[:action_select] do nil -> query |> Ecto.Query.exclude(:select) |> Ecto.Query.select([row], row) |> add_calculations(options[:calculations] || [], resource) action_select -> query |> Ecto.Query.exclude(:select) |> Ecto.Query.select([row], struct(row, ^action_select)) |> add_calculations(options[:calculations] || [], resource) end query else Ecto.Query.exclude(query, :select) end {_, results} = repo.delete_all( query, repo_opts ) if options[:return_records?] do {:ok, AshSql.Query.remap_mapped_fields(results, query)} else :ok end rescue e -> handle_raised_error(e, __STACKTRACE__, ecto_changeset, resource) end end end defp bulk_updatable_query(query, resource, atomics, calculations, context, type \\ :update) do Enum.reduce_while(atomics, {:ok, query}, fn {_, expr}, {:ok, query} -> used_aggregates = Ash.Filter.used_aggregates(expr, []) with {:ok, query} <- AshSql.Join.join_all_relationships( query, %Ash.Filter{ resource: resource, expression: expr }, left_only?: true ), {:ok, query} <- AshSql.Aggregate.add_aggregates(query, used_aggregates, resource, false, 0) do {:cont, {:ok, query}} else {:error, error} -> {:halt, {:error, error}} end end) |> case do {:ok, query} -> requires_adding_inner_join? = case type do :update -> # could potentially optimize this to avoid the subquery by shuffling free # inner joins to the top of the query has_inner_join_to_start? = case Enum.at(query.joins, 0) do nil -> false %{qual: :inner} -> true _ -> false end cond do has_inner_join_to_start? -> false Enum.any?(query.joins, &(&1.qual != :inner)) -> true Enum.any?(atomics ++ calculations, fn {_, expr} -> Ash.Filter.list_refs(expr) |> Enum.any?(&(&1.relationship_path != [])) end) -> true true -> false end :destroy -> Enum.any?(query.joins, &(&1.qual != :inner)) || Enum.any?(atomics ++ calculations, fn {_, expr} -> expr |> Ash.Filter.list_refs() |> Enum.any?(&(&1.relationship_path != [])) end) end has_exists? = Enum.any?(atomics, fn {_key, expr} -> Ash.Filter.find(expr, fn %Ash.Query.Exists{} -> true _ -> false end) end) needs_to_join? = requires_adding_inner_join? || query.distinct || query.limit || query.offset || has_exists? || query.combinations != [] query = if needs_to_join? do root_query = Ecto.Query.exclude(query, :select) root_query_result = cond do query.limit || query.offset -> with {:ok, root_query} <- AshSql.Atomics.select_atomics(resource, root_query, atomics) do {:ok, from(row in Ecto.Query.subquery(root_query), []), root_query.__ash_bindings__.expression_accumulator, atomics != []} end !Enum.empty?(query.joins) || has_exists? -> with root_query <- Ecto.Query.exclude(root_query, :order_by), {:ok, root_query} <- AshSql.Atomics.select_atomics(resource, root_query, atomics) do {:ok, from(row in Ecto.Query.subquery(root_query), []), root_query.__ash_bindings__.expression_accumulator, atomics != []} end true -> {:ok, Ecto.Query.exclude(root_query, :order_by), Map.get(root_query, :__ash_bindings__).expression_accumulator, false} end case root_query_result do {:ok, root_query, acc, selected_atomics?} -> dynamic = Enum.reduce(Ash.Resource.Info.primary_key(resource), nil, fn pkey, dynamic -> if dynamic do Ecto.Query.dynamic( [row, joining], field(row, ^pkey) == field(joining, ^pkey) and ^dynamic ) else Ecto.Query.dynamic( [row, joining], field(row, ^pkey) == field(joining, ^pkey) ) end end) faked_query = from(row in query.from.source, inner_join: limiter in ^root_query, as: ^0, on: ^dynamic ) |> AshSql.Bindings.default_bindings( query.__ash_bindings__.resource, AshSqlite.SqlImplementation, context ) |> AshSql.Bindings.merge_expr_accumulator(acc) |> then(fn query -> if selected_atomics? do Map.update!(query, :__ash_bindings__, &Map.put(&1, :atomics_in_binding, 0)) else query end end) {:ok, faked_query} {:error, error} -> {:error, error} end else {:ok, query |> Ecto.Query.exclude(:select) |> Ecto.Query.exclude(:order_by)} end case query do {:ok, query} -> Enum.reduce_while(calculations, {:ok, query}, fn {_, expr}, {:ok, query} -> used_aggregates = Ash.Filter.used_aggregates(expr, []) with {:ok, query} <- AshSql.Join.join_all_relationships( query, %Ash.Filter{ resource: resource, expression: expr }, left_only?: true ), {:ok, query} <- AshSql.Aggregate.add_aggregates(query, used_aggregates, resource, false, 0) do {:cont, {:ok, query}} else {:error, error} -> {:halt, {:error, error}} end end) {:error, error} -> {:error, error} end {:error, error} -> {:error, error} end end @impl true def sort(query, sort, _resource) do {:ok, Map.update!(query, :__ash_bindings__, &Map.put(&1, :sort, sort))} end @impl true def select(query, select, resource) do query = AshSql.Bindings.default_bindings(query, resource, AshSqlite.SqlImplementation) {:ok, from(row in query, select: struct(row, ^Enum.uniq(select)) )} end @doc false def unwrap_one([thing]), do: thing def unwrap_one([]), do: nil def unwrap_one(other), do: other @impl true def filter(query, filter, _resource, opts \\ []) do query |> AshSql.Join.join_all_relationships(filter, opts) |> case do {:ok, query} -> {:ok, AshSql.Filter.add_filter_expression(query, filter)} {:error, error} -> {:error, error} end end @impl true def add_calculations(query, calculations, resource) do AshSql.Calculation.add_calculations(query, calculations, resource, 0, true) end @doc false def get_binding(resource, path, query, type, name_match \\ nil) def get_binding(resource, path, %{__ash_bindings__: _} = query, type, name_match) do types = List.wrap(type) Enum.find_value(query.__ash_bindings__.bindings, fn {binding, %{path: candidate_path, type: binding_type} = data} -> if binding_type in types do if name_match do if data[:name] == name_match do if Ash.Resource.Info.synonymous_relationship_paths?(resource, candidate_path, path) do binding end end else if Ash.Resource.Info.synonymous_relationship_paths?(resource, candidate_path, path) do binding else false end end end _ -> nil end) end def get_binding(_, _, _, _, _), do: nil @doc false def add_binding(query, data, additional_bindings \\ 0) do current = query.__ash_bindings__.current bindings = query.__ash_bindings__.bindings new_ash_bindings = %{ query.__ash_bindings__ | bindings: Map.put(bindings, current, data), current: current + 1 + additional_bindings } %{query | __ash_bindings__: new_ash_bindings} end def add_known_binding(query, data, known_binding) do bindings = query.__ash_bindings__.bindings new_ash_bindings = %{ query.__ash_bindings__ | bindings: Map.put(bindings, known_binding, data) } %{query | __ash_bindings__: new_ash_bindings} end @impl true def rollback(resource, term) do AshSqlite.DataLayer.Info.repo(resource, :mutate).rollback(term) end defp table(resource, changeset) do changeset.context[:data_layer][:table] || AshSqlite.DataLayer.Info.table(resource) end defp raise_table_error!(resource, operation) do if AshSqlite.DataLayer.Info.polymorphic?(resource) do raise """ Could not determine table for #{operation} on #{inspect(resource)}. Polymorphic resources require that the `data_layer[:table]` context is provided. See the guide on polymorphic resources for more information. """ else raise """ Could not determine table for #{operation} on #{inspect(resource)}. """ end end defp dynamic_repo(resource, %{__ash_bindings__: %{context: %{data_layer: %{repo: repo}}}}) do repo || AshSqlite.DataLayer.Info.repo(resource, :read) end defp dynamic_repo(resource, %{context: %{data_layer: %{repo: repo}}}) do repo || AshSqlite.DataLayer.Info.repo(resource, :read) end defp dynamic_repo(resource, _) do AshSqlite.DataLayer.Info.repo(resource, :read) end defp resolve_source(resource, changeset) do if table = changeset.context[:data_layer][:table] do {table, resource} else resource end end end