This commit is contained in:
2026-07-14 22:50:46 +04:00
parent 27143319e3
commit bd19e0682b
3116 changed files with 467189 additions and 0 deletions

View File

@@ -0,0 +1,602 @@
defmodule Ecto.Adapters.MyXQL do
@moduledoc """
Adapter module for MySQL.
It uses `MyXQL` for communicating to the database.
## Options
MySQL options split in different categories described
below. All options can be given via the repository
configuration:
config :your_app, YourApp.Repo,
...
The `:prepare` option may be specified per operation:
YourApp.Repo.all(Queryable, prepare: :unnamed)
### Connection options
* `:protocol` - Set to `:socket` for using UNIX domain socket, or `:tcp` for TCP
(default: `:socket`)
* `:socket` - Connect to MySQL via UNIX sockets in the given path.
* `:hostname` - Server hostname
* `:port` - Server port (default: 3306)
* `:username` - Username
* `:password` - User password
* `:database` - the database to connect to
* `:pool` - The connection pool module, may be set to `Ecto.Adapters.SQL.Sandbox`
* `:ssl` - Accepts a list of options to enable TLS for the client connection,
or `false` to disable it. See the documentation for [Erlang's `ssl` module](`e:ssl:ssl`)
for a list of options (default: false)
* `:connect_timeout` - The timeout for establishing new connections (default: 5000)
* `:cli_protocol` - The protocol used for the mysql client connection (default: `"tcp"`).
This option is only used for `mix ecto.load` and `mix ecto.dump`,
via the `mysql` command. For more information, please check
[MySQL docs](https://dev.mysql.com/doc/en/connecting.html)
* `:socket_options` - Specifies socket configuration
* `:show_sensitive_data_on_connection_error` - show connection data and
configuration whenever there is an error attempting to connect to the
database
The `:socket_options` are particularly useful when configuring the size
of both send and receive buffers. For example, when Ecto starts with a
pool of 20 connections, the memory usage may quickly grow from 20MB to
50MB based on the operating system default values for TCP buffers. It is
advised to stick with the operating system defaults but they can be
tweaked if desired:
socket_options: [recbuf: 8192, sndbuf: 8192]
We also recommend developers to consult the `MyXQL.start_link/1` documentation
for a complete listing of all supported options.
### Storage options
* `:charset` - the database encoding (default: "utf8mb4")
* `:collation` - the collation order
* `:dump_path` - where to place dumped structures
* `:dump_prefixes` - list of prefixes that will be included in the
structure dump. For MySQL, this list must be of length 1. Multiple
prefixes are not supported. When specified, the prefixes will have
their definitions dumped along with the data in their migration table.
When it is not specified, only the configured database and its migration
table are dumped.
### After connect callback
If you want to execute a callback as soon as connection is established
to the database, you can use the `:after_connect` configuration. For
example, in your repository configuration you can add:
after_connect: {MyXQL, :query!, ["SET variable = value", []]}
You can also specify your own module that will receive the MyXQL
connection as argument.
## Limitations
There are some limitations when using Ecto with MySQL that one
needs to be aware of.
### Engine
Tables created by Ecto are guaranteed to use InnoDB, regardless
of the MySQL version.
### UUIDs
MySQL does not support UUID types. Ecto emulates them by using
`binary(16)`.
### Read after writes
Because MySQL does not support RETURNING clauses in INSERT and
UPDATE, it does not support the `:read_after_writes` option of
`Ecto.Schema.field/3`.
### DDL Transaction
MySQL does not support migrations inside transactions as it
automatically commits after some commands like CREATE TABLE.
Therefore MySQL migrations does not run inside transactions.
## Old MySQL versions
### JSON support
MySQL introduced a native JSON type in v5.7.8, if your server is
using this version or higher, you may use `:map` type for your
column in migration:
add :some_field, :map
If you're using older server versions, use a `TEXT` field instead:
add :some_field, :text
in either case, the adapter will automatically encode/decode the
value from JSON.
### usec in datetime
Old MySQL versions did not support usec in datetime while
more recent versions would round or truncate the usec value.
Therefore, in case the user decides to use microseconds in
datetimes and timestamps with MySQL, be aware of such
differences and consult the documentation for your MySQL
version.
If your version of MySQL supports microsecond precision, you
will be able to utilize Ecto's usec types.
## Multiple Result Support
MyXQL supports the execution of queries that return multiple
results, such as text queries with multiple statements separated
by semicolons or stored procedures. These can be executed with
`Ecto.Adapters.SQL.query_many/4` or the `YourRepo.query_many/3`
shortcut.
Be default, these queries will be executed with the `:query_type`
option set to `:text`. To take advantage of prepared statements
when executing a stored procedure, set the `:query_type` option
to `:binary`.
"""
# Inherit all behaviour from Ecto.Adapters.SQL
use Ecto.Adapters.SQL, driver: :myxql
# And provide a custom storage implementation
@behaviour Ecto.Adapter.Storage
@behaviour Ecto.Adapter.Structure
@default_prepare_opt :named
## Custom MySQL types
@impl true
def loaders({:array, _}, type), do: [&json_decode/1, type]
def loaders({:map, _}, type), do: [&json_decode/1, &Ecto.Type.embedded_load(type, &1, :json)]
def loaders(:map, type), do: [&json_decode/1, type]
def loaders(:float, type), do: [&float_decode/1, type]
def loaders(:boolean, type), do: [&bool_decode/1, type]
def loaders(:binary_id, type), do: [Ecto.UUID, type]
def loaders(_, type), do: [type]
defp bool_decode(<<0>>), do: {:ok, false}
defp bool_decode(<<1>>), do: {:ok, true}
defp bool_decode(<<0::size(1)>>), do: {:ok, false}
defp bool_decode(<<1::size(1)>>), do: {:ok, true}
defp bool_decode(0), do: {:ok, false}
defp bool_decode(1), do: {:ok, true}
defp bool_decode(x), do: {:ok, x}
defp float_decode(%Decimal{} = decimal), do: {:ok, Decimal.to_float(decimal)}
defp float_decode(x), do: {:ok, x}
defp json_decode(x) when is_binary(x), do: {:ok, MyXQL.json_library().decode!(x)}
defp json_decode(x), do: {:ok, x}
## Query API
@impl Ecto.Adapter.Queryable
def execute(adapter_meta, query_meta, query, params, opts) do
prepare = Keyword.get(opts, :prepare, @default_prepare_opt)
unless valid_prepare?(prepare) do
raise ArgumentError,
"expected option `:prepare` to be either `:named` or `:unnamed`, got: #{inspect(prepare)}"
end
Ecto.Adapters.SQL.execute(prepare, adapter_meta, query_meta, query, params, opts)
end
defp valid_prepare?(prepare) when prepare in [:named, :unnamed], do: true
defp valid_prepare?(_), do: false
## Storage API
@impl true
def storage_up(opts) do
database = Keyword.fetch!(opts, :database)
opts = Keyword.delete(opts, :database)
charset = opts[:charset] || "utf8mb4"
check_existence_command =
"SELECT TRUE FROM information_schema.schemata WHERE schema_name = '#{database}'"
case run_query(check_existence_command, opts) do
{:ok, %{num_rows: 1}} ->
{:error, :already_up}
_ ->
create_command =
~s(CREATE DATABASE `#{database}` DEFAULT CHARACTER SET = #{charset})
|> concat_if(opts[:collation], &"DEFAULT COLLATE = #{&1}")
case run_query(create_command, opts) do
{:ok, _} ->
:ok
{:error, %{mysql: %{name: :ER_DB_CREATE_EXISTS}}} ->
{:error, :already_up}
{:error, error} ->
{:error, Exception.message(error)}
{:exit, exit} ->
{:error, exit_to_exception(exit)}
end
end
end
defp concat_if(content, nil, _fun), do: content
defp concat_if(content, value, fun), do: content <> " " <> fun.(value)
@impl true
def storage_down(opts) do
database = Keyword.fetch!(opts, :database)
opts = Keyword.delete(opts, :database)
command = "DROP DATABASE `#{database}`"
case run_query(command, opts) do
{:ok, _} ->
:ok
{:error, %{mysql: %{name: :ER_DB_DROP_EXISTS}}} ->
{:error, :already_down}
{:error, %{mysql: %{name: :ER_BAD_DB_ERROR}}} ->
{:error, :already_down}
{:error, error} ->
{:error, Exception.message(error)}
{:exit, :killed} ->
{:error, :already_down}
{:exit, exit} ->
{:error, exit_to_exception(exit)}
end
end
@impl Ecto.Adapter.Storage
def storage_status(opts) do
database = Keyword.fetch!(opts, :database)
opts = Keyword.delete(opts, :database)
check_database_query =
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = '#{database}'"
case run_query(check_database_query, opts) do
{:ok, %{num_rows: 0}} -> :down
{:ok, %{num_rows: _num_rows}} -> :up
other -> {:error, other}
end
end
@impl true
def supports_ddl_transaction? do
false
end
@impl true
def lock_for_migrations(meta, opts, fun) do
%{opts: adapter_opts, repo: repo} = meta
if Keyword.fetch(adapter_opts, :pool_size) == {:ok, 1} do
Ecto.Adapters.SQL.raise_migration_pool_size_error()
end
opts = Keyword.merge(opts, timeout: :infinity, telemetry_options: [schema_migration: true])
{:ok, result} =
transaction(meta, opts, fn ->
lock_name = "\'ecto_#{inspect(repo)}\'"
try do
{:ok, _} = Ecto.Adapters.SQL.query(meta, "SELECT GET_LOCK(#{lock_name}, -1)", [], opts)
fun.()
after
{:ok, _} = Ecto.Adapters.SQL.query(meta, "SELECT RELEASE_LOCK(#{lock_name})", [], opts)
end
end)
result
end
@impl true
def insert(adapter_meta, schema_meta, params, on_conflict, returning, opts) do
%{source: source, prefix: prefix} = schema_meta
{_, query_params, _} = on_conflict
key = primary_key!(schema_meta, returning)
{fields, values} = :lists.unzip(params)
sql = @conn.insert(prefix, source, fields, [fields], on_conflict, [], [])
opts =
if is_nil(Keyword.get(opts, :cache_statement)) do
[{:cache_statement, "ecto_insert_#{source}_#{length(fields)}"} | opts]
else
opts
end
case Ecto.Adapters.SQL.query(adapter_meta, sql, values ++ query_params, opts) do
{:ok, %{num_rows: 0}} ->
raise "insert operation failed to insert any row in the database. " <>
"This may happen if you have trigger or other database conditions rejecting operations. " <>
"The emitted SQL was: #{sql}"
# We were used to check if num_rows was 1 or 2 (in case of upserts)
# but MariaDB supports tables with System Versioning, and in those
# cases num_rows can be more than 2.
{:ok, %{last_insert_id: last_insert_id}} ->
{:ok, last_insert_id(key, last_insert_id)}
{:error, err} ->
case @conn.to_constraints(err, source: source) do
[] -> raise err
constraints -> {:invalid, constraints}
end
end
end
defp primary_key!(%{autogenerate_id: {_, key, _type}}, [key]), do: key
defp primary_key!(_, []), do: nil
defp primary_key!(%{schema: schema}, returning) do
raise ArgumentError,
"MySQL does not support :read_after_writes in schemas for non-primary keys. " <>
"The following fields in #{inspect(schema)} are tagged as such: #{inspect(returning)}"
end
defp last_insert_id(nil, _last_insert_id), do: []
defp last_insert_id(_key, 0), do: []
defp last_insert_id(key, last_insert_id), do: [{key, last_insert_id}]
@impl true
def structure_dump(default, config) do
table = config[:migration_source] || "schema_migrations"
path = config[:dump_path] || Path.join(default, "structure.sql")
database = dump_database!(config[:dump_prefixes], config[:database])
with {:ok, versions} <- select_versions(database, table, config),
{:ok, contents} <- mysql_dump(database, config),
{:ok, contents} <- append_versions(table, versions, contents) do
File.mkdir_p!(Path.dirname(path))
File.write!(path, contents)
{:ok, path}
end
end
defp dump_database!([prefix], _), do: prefix
defp dump_database!(nil, config_database), do: config_database
defp dump_database!(_, _) do
raise ArgumentError,
"cannot dump multiple prefixes with MySQL. Please run the command separately for each prefix."
end
defp select_versions(database, table, config) do
case run_query(~s[SELECT version FROM `#{database}`.`#{table}` ORDER BY version], config) do
{:ok, %{rows: rows}} -> {:ok, Enum.map(rows, &hd/1)}
{:error, %{mysql: %{name: :ER_NO_SUCH_TABLE}}} -> {:ok, []}
{:error, _} = error -> error
{:exit, exit} -> {:error, exit_to_exception(exit)}
end
end
defp mysql_dump(database, config) do
args = ["--no-data", "--routines", "--no-create-db", database]
case run_with_cmd("mysqldump", config, args) do
{output, 0} -> {:ok, output}
{output, _} -> {:error, output}
end
end
defp append_versions(_table, [], contents) do
{:ok, contents}
end
defp append_versions(table, versions, contents) do
{:ok,
contents <>
Enum.map_join(versions, &~s[INSERT INTO `#{table}` (version) VALUES (#{&1});\n])}
end
@impl true
def structure_load(default, config) do
path = config[:dump_path] || Path.join(default, "structure.sql")
case File.read(path) do
{:ok, contents} ->
args = [
"--silent",
"--batch",
"--unbuffered",
"--init-command=SET FOREIGN_KEY_CHECKS = 0;",
"--database",
config[:database]
]
case run_with_port("mysql", config, args, contents) do
{_output, 0} -> {:ok, path}
{output, _} -> {:error, output}
end
{:error, reason} ->
{:error, "could not read #{inspect(path)}: #{:file.format_error(reason)}"}
end
end
@impl true
def dump_cmd(args, opts \\ [], config) when is_list(config) and is_list(args) do
args =
if database = config[:database] do
args ++ [database]
else
args
end
run_with_cmd("mysqldump", config, args, opts)
end
## Helpers
defp run_query(sql, opts) do
{:ok, _} = Application.ensure_all_started(:ecto_sql)
{:ok, _} = Application.ensure_all_started(:myxql)
opts =
opts
|> Keyword.drop([:name, :log, :pool, :pool_size])
|> Keyword.put(:backoff_type, :stop)
|> Keyword.put(:max_restarts, 0)
task =
Task.Supervisor.async_nolink(Ecto.Adapters.SQL.StorageSupervisor, fn ->
{:ok, conn} = MyXQL.start_link(opts)
value = MyXQL.query(conn, sql, [], opts)
GenServer.stop(conn)
value
end)
timeout = Keyword.get(opts, :timeout, 15_000)
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, {:ok, result}} ->
{:ok, result}
{:ok, {:error, error}} ->
{:error, error}
{:exit, exit} ->
{:exit, exit}
nil ->
{:error, RuntimeError.exception("command timed out")}
end
end
defp exit_to_exception({%{__struct__: struct} = error, _})
when struct in [MyXQL.Error, DBConnection.Error],
do: error
defp exit_to_exception(reason), do: RuntimeError.exception(Exception.format_exit(reason))
defp run_with_cmd(cmd, opts, opt_args, cmd_opts \\ []) do
unless System.find_executable(cmd) do
raise "could not find executable `#{cmd}` in path, " <>
"please guarantee it is available before running ecto commands"
end
{args, env} = args_env(opts, opt_args)
cmd_opts =
cmd_opts
|> Keyword.put_new(:stderr_to_stdout, true)
|> Keyword.update(:env, env, &Enum.concat(env, &1))
System.cmd(cmd, args, cmd_opts)
end
defp run_with_port(cmd, opts, opt_args, contents) do
abs_cmd = System.find_executable(cmd)
unless abs_cmd do
raise "could not find executable `#{cmd}` in path, " <>
"please guarantee it is available before running ecto commands"
end
abs_cmd = String.to_charlist(abs_cmd)
{args, env} = args_env(opts, opt_args)
port_opts = [
:use_stdio,
:exit_status,
:binary,
:hide,
:stderr_to_stdout,
env: validate_env(env),
args: args
]
port = Port.open({:spawn_executable, abs_cmd}, port_opts)
Port.command(port, contents)
# Use this as a signal to close the port since we cannot
# send an exit command to mysql in batch mode
Port.command(port, ";SELECT '__ECTO_EOF__';\n")
collect_output(port, "")
end
defp args_env(opts, opt_args) do
env =
if password = opts[:password] do
[{"MYSQL_PWD", password}]
else
[]
end
host = opts[:hostname] || System.get_env("MYSQL_HOST") || "localhost"
port = opts[:port] || System.get_env("MYSQL_TCP_PORT") || "3306"
protocol = opts[:cli_protocol] || System.get_env("MYSQL_CLI_PROTOCOL") || "tcp"
user_args =
if username = opts[:username] do
["--user", username]
else
[]
end
args =
[
"--host",
host,
"--port",
to_string(port),
"--protocol",
protocol
] ++ user_args ++ opt_args
{args, env}
end
defp validate_env(enum) do
Enum.map(enum, fn
{k, nil} ->
{String.to_charlist(k), false}
{k, v} ->
{String.to_charlist(k), String.to_charlist(v)}
other ->
raise ArgumentError, "invalid environment key-value #{inspect(other)}"
end)
end
defp collect_output(port, acc) do
receive do
{^port, {:data, data}} ->
acc = acc <> data
if acc =~ "__ECTO_EOF__" do
Port.close(port)
{acc, 0}
else
collect_output(port, acc)
end
{^port, {:exit_status, status}} ->
{acc, status}
end
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,548 @@
defmodule Ecto.Adapters.Postgres do
@moduledoc """
Adapter module for PostgreSQL.
It uses `Postgrex` for communicating to the database.
## Features
* Full query support (including joins, preloads and associations)
* Support for transactions
* Support for data migrations
* Support for ecto.create and ecto.drop operations
* Support for transactional tests via `Ecto.Adapters.SQL`
## Options
Postgres options split in different categories described
below. All options can be given via the repository
configuration:
config :your_app, YourApp.Repo,
...
The `:prepare` option may be specified per operation:
YourApp.Repo.all(Queryable, prepare: :unnamed)
### Migration options
* `:migration_lock` - prevent multiple nodes from running migrations at the same
time by obtaining a lock. The value `:table_lock` will lock migrations by wrapping
the entire migration inside a database transaction, including inserting the
migration version into the migration source (by default, "schema_migrations").
You may alternatively select `:pg_advisory_lock` which has the benefit
of allowing concurrent operations such as creating indexes. (default: `:table_lock`)
When using the `:pg_advisory_lock` migration lock strategy and Ecto cannot obtain
the lock due to another instance occupying the lock, Ecto will wait for 5 seconds
and then retry infinity times. This is configurable on the repo with keys
`:migration_advisory_lock_retry_interval_ms` and `:migration_advisory_lock_max_tries`.
If the retries are exhausted, the migration will fail.
Some downsides to using advisory locks is that some Postgres-compatible systems or plugins
may not support session level locks well and therefore result in inconsistent behavior.
For example, PgBouncer when using pool_modes other than session won't work well with
advisory locks. CockroachDB is another system that is designed in a way that advisory
locks don't make sense for their distributed database.
### Connection options
* `:hostname` - Server hostname
* `:socket_dir` - Connect to Postgres via UNIX sockets in the given directory
The socket name is derived based on the port. This is the preferred method
for configuring sockets and it takes precedence over the hostname. If you are
connecting to a socket outside of the Postgres convention, use `:socket` instead;
* `:socket` - Connect to Postgres via UNIX sockets in the given path.
This option takes precedence over the `:hostname` and `:socket_dir`
* `:username` - Username
* `:password` - User password
* `:port` - Server port (default: 5432)
* `:database` - the database to connect to
* `:maintenance_database` - Specifies the name of the database to connect to when
creating or dropping the database. Defaults to `"postgres"`
* `:pool` - The connection pool module, may be set to `Ecto.Adapters.SQL.Sandbox`
* `:ssl` - Accepts a list of options to enable TLS for the client connection,
or `false` to disable it. See the documentation for [Erlang's `ssl` module](`e:ssl:ssl`)
for a list of options (default: false)
* `:parameters` - Keyword list of connection parameters
* `:connect_timeout` - The timeout for establishing new connections (default: 5000)
* `:prepare` - How to prepare queries, either `:named` to use named queries
or `:unnamed` to force unnamed queries (default: `:named`)
* `:socket_options` - Specifies socket configuration
* `:show_sensitive_data_on_connection_error` - show connection data and
configuration whenever there is an error attempting to connect to the
database
The `:socket_options` are particularly useful when configuring the size
of both send and receive buffers. For example, when Ecto starts with a
pool of 20 connections, the memory usage may quickly grow from 20MB to
50MB based on the operating system default values for TCP buffers. It is
advised to stick with the operating system defaults but they can be
tweaked if desired:
socket_options: [recbuf: 8192, sndbuf: 8192]
We also recommend developers to consult the `Postgrex.start_link/1`
documentation for a complete listing of all supported options.
### Storage options
* `:encoding` - the database encoding (default: "UTF8")
or `:unspecified` to remove encoding parameter (alternative engine compatibility)
* `:template` - the template to create the database from
* `:lc_collate` - the collation order
* `:lc_ctype` - the character classification
* `:dump_path` - where to place dumped structures
* `:dump_prefixes` - list of prefixes that will be included in the structure dump.
When specified, the prefixes will have their definitions dumped along with the
data in their migration table. When it is not specified, the configured
database has the definitions dumped from all of its schemas but only
the data from the migration table from the `public` schema is included.
* `:force_drop` - force the database to be dropped even
if it has connections to it (requires PostgreSQL 13+)
### After connect callback
If you want to execute a callback as soon as connection is established
to the database, you can use the `:after_connect` configuration. For
example, in your repository configuration you can add:
after_connect: {Postgrex, :query!, ["SET search_path TO global_prefix", []]}
You can also specify your own module that will receive the Postgrex
connection as argument.
## Extensions
Both PostgreSQL and its adapter for Elixir, Postgrex, support an
extension system. If you want to use custom extensions for Postgrex
alongside Ecto, you must define a type module with your extensions.
Create a new file anywhere in your application with the following:
Postgrex.Types.define(MyApp.PostgresTypes, [MyExtension.Foo, MyExtensionBar])
Once your type module is defined, you can configure the repository to use it:
config :my_app, MyApp.Repo, types: MyApp.PostgresTypes
## Unix socket connection
You may desire to communicate with Postgres via Unix sockets.
If your PG server is started on the same machine as your code, you could check that:
```bash
% sudo grep unix_socket_directories /var/lib/postgres/data/postgresql.conf
unix_socket_directories = '/run/postgresql'
```
```bash
% ls -lah /run/postgresql
итого 4,0K
drwxr-xr-x 2 postgres postgres 80 июн 4 10:58 .
drwxr-xr-x 35 root root 840 июн 4 21:02 ..
srwxrwxrwx 1 postgres postgres 0 июн 5 07:41 .s.PGSQL.5432
-rw------- 1 postgres postgres 61 июн 5 07:41 .s.PGSQL.5432.lock
```
So you have postgresql started and listening on the socket.
Then you may use it as follows:
config :your_app, YourApp.Repo,
socket_dir: "/run/postgresql"
"""
# Inherit all behaviour from Ecto.Adapters.SQL
use Ecto.Adapters.SQL, driver: :postgrex
require Logger
# And provide a custom storage implementation
@behaviour Ecto.Adapter.Storage
@behaviour Ecto.Adapter.Structure
@default_maintenance_database "postgres"
@default_prepare_opt :named
@doc """
All Ecto extensions for Postgrex.
Currently Ecto does not define any of its own extensions for Postgrex.
If this changes in a future release, you will need to call this function
when defining your own custom extensions:
Postgrex.Types.define(MyApp.PostgresTypes,
[MyExtension.Foo, MyExtensionBar] ++ Ecto.Adapters.Postgres.extensions())
"""
def extensions do
[]
end
# Support arrays in place of IN
@impl true
def dumpers({:map, _}, type), do: [&Ecto.Type.embedded_dump(type, &1, :json)]
def dumpers({:in, sub}, {:in, sub}), do: [{:array, sub}]
def dumpers(:binary_id, type), do: [type, Ecto.UUID]
def dumpers(_, type), do: [type]
## Query API
@impl Ecto.Adapter.Queryable
def execute(adapter_meta, query_meta, query, params, opts) do
prepare = Keyword.get(opts, :prepare, @default_prepare_opt)
unless valid_prepare?(prepare) do
raise ArgumentError,
"expected option `:prepare` to be either `:named` or `:unnamed`, got: #{inspect(prepare)}"
end
Ecto.Adapters.SQL.execute(prepare, adapter_meta, query_meta, query, params, opts)
end
defp valid_prepare?(prepare) when prepare in [:named, :unnamed], do: true
defp valid_prepare?(_), do: false
## Storage API
@impl true
def storage_up(opts) do
database = Keyword.fetch!(opts, :database)
encoding = if opts[:encoding] == :unspecified, do: nil, else: opts[:encoding] || "UTF8"
maintenance_database = Keyword.get(opts, :maintenance_database, @default_maintenance_database)
opts = Keyword.put(opts, :database, maintenance_database)
check_existence_command = "SELECT FROM pg_database WHERE datname = '#{database}'"
case run_query(check_existence_command, opts) do
{:ok, %{num_rows: 1}} ->
{:error, :already_up}
_ ->
create_command =
~s(CREATE DATABASE "#{database}")
|> concat_if(encoding, &"ENCODING '#{&1}'")
|> concat_if(opts[:template], &"TEMPLATE=#{&1}")
|> concat_if(opts[:lc_ctype], &"LC_CTYPE='#{&1}'")
|> concat_if(opts[:lc_collate], &"LC_COLLATE='#{&1}'")
case run_query(create_command, opts) do
{:ok, _} ->
:ok
{:error, %{postgres: %{code: :duplicate_database}}} ->
{:error, :already_up}
{:error, error} ->
{:error, Exception.message(error)}
end
end
end
defp concat_if(content, nil, _), do: content
defp concat_if(content, false, _), do: content
defp concat_if(content, value, fun), do: content <> " " <> fun.(value)
@impl true
def storage_down(opts) do
database = Keyword.fetch!(opts, :database)
command =
"DROP DATABASE \"#{database}\""
|> concat_if(opts[:force_drop], fn _ -> "WITH (FORCE)" end)
maintenance_database = Keyword.get(opts, :maintenance_database, @default_maintenance_database)
opts = Keyword.put(opts, :database, maintenance_database)
case run_query(command, opts) do
{:ok, _} ->
:ok
{:error, %{postgres: %{code: :invalid_catalog_name}}} ->
{:error, :already_down}
{:error, error} ->
{:error, Exception.message(error)}
end
end
@impl Ecto.Adapter.Storage
def storage_status(opts) do
database = Keyword.fetch!(opts, :database)
maintenance_database = Keyword.get(opts, :maintenance_database, @default_maintenance_database)
opts = Keyword.put(opts, :database, maintenance_database)
check_database_query =
"SELECT datname FROM pg_catalog.pg_database WHERE datname = '#{database}'"
case run_query(check_database_query, opts) do
{:ok, %{num_rows: 0}} -> :down
{:ok, %{num_rows: _num_rows}} -> :up
other -> {:error, other}
end
end
@impl true
def supports_ddl_transaction? do
true
end
@impl true
def lock_for_migrations(meta, opts, fun) do
%{opts: adapter_opts, repo: repo} = meta
if Keyword.fetch(adapter_opts, :pool_size) == {:ok, 1} do
Ecto.Adapters.SQL.raise_migration_pool_size_error()
end
opts = Keyword.merge(opts, timeout: :infinity, telemetry_options: [schema_migration: true])
config = repo.config()
lock_strategy = Keyword.get(config, :migration_lock, :table_lock)
do_lock_for_migrations(lock_strategy, meta, opts, config, fun)
end
defp do_lock_for_migrations(:pg_advisory_lock, meta, opts, config, fun) do
lock = :erlang.phash2({:ecto, opts[:prefix], meta.repo})
retry_state = %{
retry_interval_ms: config[:migration_advisory_lock_retry_interval_ms] || 5000,
max_tries: config[:migration_advisory_lock_max_tries] || :infinity,
tries: 0
}
advisory_lock(meta, opts, lock, retry_state, fun)
end
defp do_lock_for_migrations(:table_lock, meta, opts, _config, fun) do
{:ok, res} =
transaction(meta, opts, fn ->
# SHARE UPDATE EXCLUSIVE MODE is the first lock that locks
# itself but still allows updates to happen, see
# # https://www.postgresql.org/docs/9.4/explicit-locking.html
source = Keyword.get(opts, :migration_source, "schema_migrations")
table = if prefix = opts[:prefix], do: ~s|"#{prefix}"."#{source}"|, else: ~s|"#{source}"|
lock_statement = "LOCK TABLE #{table} IN SHARE UPDATE EXCLUSIVE MODE"
{:ok, _} = Ecto.Adapters.SQL.query(meta, lock_statement, [], opts)
fun.()
end)
res
end
defp advisory_lock(meta, opts, lock, retry_state, fun) do
result =
checkout(meta, opts, fn ->
case Ecto.Adapters.SQL.query(meta, "SELECT pg_try_advisory_lock(#{lock})", [], opts) do
{:ok, %{rows: [[true]]}} ->
try do
{:ok, fun.()}
after
release_advisory_lock(meta, opts, lock)
end
_ ->
:no_advisory_lock
end
end)
case result do
{:ok, fun_result} ->
fun_result
:no_advisory_lock ->
maybe_retry_advisory_lock(meta, opts, lock, retry_state, fun)
end
end
defp release_advisory_lock(meta, opts, lock) do
case Ecto.Adapters.SQL.query(meta, "SELECT pg_advisory_unlock(#{lock})", [], opts) do
{:ok, %{rows: [[true]]}} ->
:ok
_ ->
raise "failed to release advisory lock"
end
end
defp maybe_retry_advisory_lock(meta, opts, lock, retry_state, fun) do
%{retry_interval_ms: interval, max_tries: max_tries, tries: tries} = retry_state
if max_tries != :infinity && max_tries <= tries do
raise "failed to obtain advisory lock. Tried #{max_tries} times waiting #{interval}ms between tries"
else
if Keyword.get(opts, :log_migrator_sql, false) do
Logger.info(
"Migration lock occupied for #{inspect(meta.repo)}. Retry #{tries + 1}/#{max_tries} at #{interval}ms intervals."
)
end
Process.sleep(interval)
retry_state = %{retry_state | tries: tries + 1}
advisory_lock(meta, opts, lock, retry_state, fun)
end
end
@impl true
def structure_dump(default, config) do
table = config[:migration_source] || "schema_migrations"
with {:ok, versions} <- select_versions(table, config),
{:ok, path} <- pg_dump(default, config),
do: append_versions(table, versions, path)
end
defp select_versions(table, config) do
prefixes = config[:dump_prefixes] || ["public"]
result =
Enum.reduce_while(prefixes, [], fn prefix, versions ->
case run_query(~s[SELECT version FROM #{prefix}."#{table}" ORDER BY version], config) do
{:ok, %{rows: rows}} -> {:cont, Enum.map(rows, &{prefix, hd(&1)}) ++ versions}
{:error, %{postgres: %{code: :undefined_table}}} -> {:cont, versions}
{:error, _} = error -> {:halt, error}
end
end)
case result do
{:error, _} = error -> error
versions -> {:ok, versions}
end
end
defp pg_dump(default, config) do
path = config[:dump_path] || Path.join(default, "structure.sql")
prefixes = config[:dump_prefixes] || []
non_prefix_args = ["--file", path, "--schema-only", "--no-acl", "--no-owner"]
args =
Enum.reduce(prefixes, non_prefix_args, fn prefix, acc ->
["-n", prefix | acc]
end)
File.mkdir_p!(Path.dirname(path))
case run_with_cmd("pg_dump", config, args) do
{_output, 0} ->
{:ok, path}
{output, _} ->
{:error, output}
end
end
defp append_versions(_table, [], path) do
{:ok, path}
end
defp append_versions(table, versions, path) do
sql =
Enum.map_join(versions, fn {prefix, version} ->
~s[INSERT INTO #{prefix}."#{table}" (version) VALUES (#{version});\n]
end)
File.open!(path, [:append], fn file ->
IO.write(file, sql)
end)
{:ok, path}
end
@impl true
def structure_load(default, config) do
path = config[:dump_path] || Path.join(default, "structure.sql")
args = ["--quiet", "--file", path, "-vON_ERROR_STOP=1", "--single-transaction"]
case run_with_cmd("psql", config, args) do
{_output, 0} -> {:ok, path}
{output, _} -> {:error, output}
end
end
@impl true
def dump_cmd(args, opts \\ [], config) when is_list(config) and is_list(args),
do: run_with_cmd("pg_dump", config, args, opts)
## Helpers
defp run_query(sql, opts) do
{:ok, _} = Application.ensure_all_started(:ecto_sql)
{:ok, _} = Application.ensure_all_started(:postgrex)
opts =
opts
|> Keyword.drop([:name, :log, :pool, :pool_size])
|> Keyword.put(:backoff_type, :stop)
|> Keyword.put(:max_restarts, 0)
task =
Task.Supervisor.async_nolink(Ecto.Adapters.SQL.StorageSupervisor, fn ->
{:ok, conn} = Postgrex.start_link(opts)
value = Postgrex.query(conn, sql, [], opts)
GenServer.stop(conn)
value
end)
timeout = Keyword.get(opts, :timeout, 15_000)
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, {:ok, result}} ->
{:ok, result}
{:ok, {:error, error}} ->
{:error, error}
{:exit, {%{__struct__: struct} = error, _}}
when struct in [Postgrex.Error, DBConnection.Error] ->
{:error, error}
{:exit, reason} ->
{:error, RuntimeError.exception(Exception.format_exit(reason))}
nil ->
{:error, RuntimeError.exception("command timed out")}
end
end
defp run_with_cmd(cmd, opts, opt_args, cmd_opts \\ []) do
unless System.find_executable(cmd) do
raise "could not find executable `#{cmd}` in path, " <>
"please guarantee it is available before running ecto commands"
end
env = [{"PGCONNECT_TIMEOUT", "10"}]
env =
if password = opts[:password] do
[{"PGPASSWORD", password} | env]
else
env
end
args = []
args = if username = opts[:username], do: ["--username", username | args], else: args
args = if port = opts[:port], do: ["--port", to_string(port) | args], else: args
args = if database = opts[:database], do: ["--dbname", database | args], else: args
host = opts[:socket_dir] || opts[:hostname] || System.get_env("PGHOST") || "localhost"
if opts[:socket] do
IO.warn(
":socket option is ignored when connecting in structure_load/2 and structure_dump/2," <>
" use :socket_dir or :hostname instead"
)
end
args = ["--host", host | args]
args = args ++ opt_args
cmd_opts =
cmd_opts
|> Keyword.put_new(:stderr_to_stdout, true)
|> Keyword.update(:env, env, &Enum.concat(env, &1))
System.cmd(cmd, args, cmd_opts)
end
end

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
defmodule Ecto.Adapters.SQL.Application do
@moduledoc false
use Application
def start(_type, _args) do
children = [
{DynamicSupervisor, strategy: :one_for_one, name: Ecto.MigratorSupervisor},
{Task.Supervisor, name: Ecto.Adapters.SQL.StorageSupervisor}
]
opts = [strategy: :one_for_one, name: Ecto.Adapters.SQL.Supervisor]
Supervisor.start_link(children, opts)
end
end

View File

@@ -0,0 +1,154 @@
defmodule Ecto.Adapters.SQL.Connection do
@moduledoc """
Specifies the behaviour to be implemented by all SQL connections.
"""
@typedoc "The query name"
@type name :: String.t()
@typedoc "The SQL statement"
@type statement :: String.t()
@typedoc "The cached query which is a DBConnection Query"
@type cached :: map
@type connection :: DBConnection.conn()
@type params :: [term]
@doc """
Receives options and returns `DBConnection` supervisor child
specification.
"""
@callback child_spec(options :: Keyword.t()) :: :supervisor.child_spec() | {module, Keyword.t()}
@doc """
Prepares and executes the given query with `DBConnection`.
"""
@callback prepare_execute(connection, name, statement, params, options :: Keyword.t()) ::
{:ok, cached, term} | {:error, Exception.t()}
@doc """
Executes a cached query.
"""
@callback execute(connection, cached, params, options :: Keyword.t()) ::
{:ok, cached, term} | {:ok, term} | {:error | :reset, Exception.t()}
@doc """
Runs the given statement as a query.
"""
@callback query(connection, statement, params, options :: Keyword.t()) ::
{:ok, term} | {:error, Exception.t()}
@doc """
Runs the given statement as a multi-result query.
"""
@callback query_many(connection, statement, params, options :: Keyword.t()) ::
{:ok, term} | {:error, Exception.t()}
@doc """
Returns a stream that prepares and executes the given query with
`DBConnection`.
"""
@callback stream(connection, statement, params, options :: Keyword.t()) ::
Enum.t()
@doc """
Receives the exception returned by `c:query/4`.
The constraints are in the keyword list and must return the
constraint type, like `:unique`, and the constraint name as
a string, for example:
[unique: "posts_title_index"]
Must return an empty list if the error does not come
from any constraint.
"""
@callback to_constraints(exception :: Exception.t(), options :: Keyword.t()) :: Keyword.t()
## Queries
@doc """
Receives a query and must return a SELECT query.
"""
@callback all(query :: Ecto.Query.t()) :: iodata
@doc """
Receives a query and values to update and must return an UPDATE query.
"""
@callback update_all(query :: Ecto.Query.t()) :: iodata
@doc """
Receives a query and must return a DELETE query.
"""
@callback delete_all(query :: Ecto.Query.t()) :: iodata
@doc """
Returns an INSERT for the given `rows` in `table` returning
the given `returning`.
"""
@callback insert(
prefix :: String.t(),
table :: String.t(),
header :: [atom],
rows :: [[atom | nil]],
on_conflict :: Ecto.Adapter.Schema.on_conflict(),
returning :: [atom],
placeholders :: [term]
) :: iodata
@doc """
Returns an UPDATE for the given `fields` in `table` filtered by
`filters` returning the given `returning`.
"""
@callback update(
prefix :: String.t(),
table :: String.t(),
fields :: [atom],
filters :: [atom],
returning :: [atom]
) :: iodata
@doc """
Returns a DELETE for the `filters` returning the given `returning`.
"""
@callback delete(
prefix :: String.t(),
table :: String.t(),
filters :: [atom],
returning :: [atom]
) :: iodata
@doc """
Executes an EXPLAIN query or similar depending on the adapter to obtains statistics of the given query.
Receives the `connection`, `query`, `params` for the query,
and all `opts` including those related to the EXPLAIN statement and shared opts.
Must execute the explain query and return the result.
"""
@callback explain_query(
connection,
query :: String.t(),
params :: Keyword.t(),
opts :: Keyword.t()
) ::
{:ok, term} | {:error, Exception.t()}
## DDL
@doc """
Receives a DDL command and returns a query that executes it.
"""
@callback execute_ddl(command :: Ecto.Adapter.Migration.command()) :: String.t() | [iodata]
@doc """
Receives a query result and returns a list of logs.
"""
@callback ddl_logs(result :: term) :: [{Logger.level(), Logger.message(), Logger.metadata()}]
@doc """
Returns a queryable to check if the given `table` exists.
"""
@callback table_exists_query(table :: String.t()) :: {iodata, [term]}
end

View File

@@ -0,0 +1,705 @@
defmodule Ecto.Adapters.SQL.Sandbox do
@moduledoc ~S"""
A pool for concurrent transactional tests.
The sandbox pool is implemented on top of an ownership mechanism.
When started, the pool is in automatic mode, which means the
repository will automatically check connections out as with any
other pool.
The `mode/2` function can be used to change the pool mode from
automatic to either manual or shared. In the latter two modes,
the connection must be explicitly checked out before use.
When explicit checkouts are made, the sandbox will wrap the
connection in a transaction by default and control who has
access to it. This means developers have a safe mechanism for
running concurrent tests against the database.
## Database support
While both PostgreSQL and MySQL support SQL Sandbox, only PostgreSQL
supports concurrent tests while running the SQL Sandbox. Therefore, do
not run concurrent tests with MySQL as you may run into deadlocks due to
its transaction implementation.
## Example
The first step is to configure your database to use the
`Ecto.Adapters.SQL.Sandbox` pool. You set those options in your
`config/config.exs` (or preferably `config/test.exs`) if you
haven't yet:
config :my_app, Repo,
pool: Ecto.Adapters.SQL.Sandbox
Now with the test database properly configured, you can write
transactional tests:
# At the end of your test_helper.exs
# Set the pool mode to manual for explicit checkouts
Ecto.Adapters.SQL.Sandbox.mode(Repo, :manual)
defmodule PostTest do
# Once the mode is manual, tests can also be async
use ExUnit.Case, async: true
setup do
# Explicitly get a connection before each test
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Repo)
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
:ok
end
test "create post" do
# Use the repository as usual
assert %Post{} = Repo.insert!(%Post{})
end
end
## Collaborating processes
The example above is straight-forward because we have only
a single process using the database connection. However,
sometimes a test may need to interact with multiple processes,
all using the same connection so they all belong to the same
transaction.
Before we discuss solutions, let's see what happens if we try
to use a connection from a new process without explicitly
checking it out first:
setup do
# Explicitly get a connection before each test
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
end
test "calls worker that runs a query" do
GenServer.call(MyApp.Worker, :run_query)
end
The test above will fail with an error similar to:
** (DBConnection.OwnershipError) cannot find ownership process for #PID<0.35.0>
That's because the `setup` block is checking out the connection only
for the test process. Once the worker attempts to perform a query,
there is no connection assigned to it and it will fail.
The sandbox module provides two ways of doing so, via allowances or
by running in shared mode.
### Allowances
The idea behind allowances is that you can explicitly tell a process
which checked out connection it should use, allowing multiple processes
to collaborate over the same connection. Let's give it a try:
test "calls worker that runs a query" do
allow = Process.whereis(MyApp.Worker)
Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), allow)
GenServer.call(MyApp.Worker, :run_query)
end
And that's it, by calling `allow/3`, we are explicitly assigning
the parent's connection (i.e. the test process' connection) to
the task.
Besides calling `allow/3` allowance can also be provided to processes
via [Caller Tracking](`m:Task#module-ancestor-and-caller-tracking`).
Because allowances use an explicit mechanism, their advantage
is that you can still run your tests in async mode. The downside
is that you need to explicitly control and allow every single
process. This is not always possible. In such cases, you will
want to use shared mode.
### Shared mode
Shared mode allows a process to share its connection with any other
process automatically, without relying on explicit allowances.
Let's change the example above to use shared mode:
setup do
# Explicitly get a connection before each test
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
# Setting the shared mode must be done only after checkout
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
end
test "calls worker that runs a query" do
GenServer.call(MyApp.Worker, :run_query)
end
By calling `mode({:shared, self()})`, any process that needs
to talk to the database will now use the same connection as the
one checked out by the test process during the `setup` block.
Make sure to always check a connection out before setting the mode
to `{:shared, self()}`.
The advantage of shared mode is that by calling a single function,
you will ensure all upcoming processes and operations will use that
shared connection, without a need to explicitly allow them. The
downside is that tests can no longer run concurrently in shared mode.
Also, beware that if the test process terminates while the worker is
using the connection, the connection will be taken away from the worker,
which will error. Therefore it is important to guarantee the work is done
before the test concludes. In the example above, we are using a `call`,
which is synchronous, avoiding the problem, but you may need to explicitly
flush the worker or terminate it under such scenarios in your tests.
### Summing up
There are two mechanisms for explicit ownerships:
* Using allowances - requires explicit allowances.
Tests may run concurrently.
* Using shared mode - does not require explicit allowances.
Tests cannot run concurrently.
## FAQ
When running the sandbox mode concurrently, developers may run into
issues we explore in the upcoming sections.
### "owner exited"
In some situations, you may see error reports similar to the one below:
23:59:59.999 [error] Postgrex.Protocol (#PID<>) disconnected:
** (DBConnection.Error) owner #PID<> exited
Client #PID<> is still using a connection from owner
Such errors are usually followed by another error report from another
process that failed while executing a database query.
To understand the failure, we need to answer the question: who are the
owner and client processes? The owner process is the one that checks
out the connection, which, in the majority of cases, is the test process,
the one running your tests. In other words, the error happens because
the test process has finished, either because the test succeeded or
because it failed, while the client process was trying to get information
from the database. Since the owner process, the one that owns the
connection, no longer exists, Ecto will check the connection back in
and notify the client process using the connection that the connection
owner is no longer available.
This can happen in different situations. For example, imagine you query
a GenServer in your test that is using a database connection:
test "gets results from GenServer" do
{:ok, pid} = MyAppServer.start_link()
Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid)
assert MyAppServer.get_my_data_fast(timeout: 1000) == [...]
end
In the test above, we spawn the server and allow it to perform database
queries using the connection owned by the test process. Since we gave
a timeout of 1 second, in case the database takes longer than one second
to reply, the test process will fail, due to the timeout, making the
"owner down" message to be printed because the server process is still
waiting on a connection reply.
In some situations, such failures may be intermittent. Imagine that you
allow a process that queries the database every half second:
test "queries periodically" do
{:ok, pid} = PeriodicServer.start_link()
Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid)
# assertions
end
Because the server is querying the database from time to time, there is
a chance that, when the test exits, the periodic process may be querying
the database, regardless of test success or failure.
To address this, you can tell ExUnit to manage your processes:
test "queries periodically" do
pid = start_supervised!(PeriodicServer)
Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid)
# assertions
end
By using `start_supervised!/1`, ExUnit guarantees the process finishes
before your test (the connection owner).
In some situations, however, the dynamic processes are directly started
inside a `DynamicSupervisor` or a `Task.Supervisor`. You can guarantee
proper termination in such scenarios by adding an `on_exit` callback
that waits until all supervised children terminate:
on_exit(fn ->
for {_, pid, _, _} <- DynamicSupervisor.which_children(MyApp.DynamicSupervisor) do
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, _, _, _}, :infinity
end
end)
### "owner timed out because it owned the connection for longer than Nms"
In some situations, you may see error reports similar to the one below:
09:56:43.081 [error] Postgrex.Protocol (#PID<>) disconnected:
** (DBConnection.ConnectionError) owner #PID<> timed out
because it owned the connection for longer than 120000ms
If you have a long running test (or you're debugging with IEx.pry),
the timeout for the connection ownership may be too short. You can
increase the timeout by setting the `:ownership_timeout` options for
your repo config in `config/config.exs` (or preferably in `config/test.exs`):
config :my_app, MyApp.Repo,
ownership_timeout: NEW_TIMEOUT_IN_MILLISECONDS
The `:ownership_timeout` option is part of `DBConnection.Ownership`
and defaults to 120000ms. Timeouts are given as integers in milliseconds.
Alternately, if this is an issue for only a handful of long-running tests,
you can pass an `:ownership_timeout` option when calling
`Ecto.Adapters.SQL.Sandbox.checkout/2` instead of setting a longer timeout
globally in your config.
### Deferred constraints
Some databases allow to defer constraint validation to the transaction
commit time, instead of the particular statement execution time. This
feature, for instance, allows for a cyclic foreign key referencing.
Since the SQL Sandbox mode rolls back transactions, tests might report
false positives because deferred constraints are never checked by the
database. To manually force deferred constraints validation when using
PostgreSQL use the following line right at the end of your test case:
Repo.query!("SET CONSTRAINTS ALL IMMEDIATE")
### Database locks and deadlocks
Since the sandbox relies on concurrent transactional tests, there is
a chance your tests may trigger deadlocks in your database. This is
specially true with MySQL, where the solutions presented here are not
enough to avoid deadlocks and therefore making the use of concurrent tests
with MySQL prohibited.
However, even on databases like PostgreSQL, performance degradations or
deadlocks may still occur. For example, imagine a "users" table with a
unique index on the "email" column. Now consider multiple tests are
trying to insert the same user email to the database. They will attempt
to retrieve the same database lock, causing only one test to succeed and
run while all other tests wait for the lock.
In other situations, two different tests may proceed in a way that
each test retrieves locks desired by the other, leading to a situation
that cannot be resolved, a deadlock. For instance:
```text
Transaction 1: Transaction 2:
begin
begin
update posts where id = 1
update posts where id = 2
update posts where id = 1
update posts where id = 2
**deadlock**
```
There are different ways to avoid such problems. One of them is
to make sure your tests work on distinct data. Regardless of
your choice between using fixtures or factories for test data,
make sure you get a new set of data per test. This is specially
important for data that is meant to be unique like user emails.
For example, instead of:
def insert_user do
Repo.insert!(%User{email: "sample@example.com"})
end
prefer:
def insert_user do
Repo.insert!(%User{email: "sample-#{counter()}@example.com"})
end
defp counter do
System.unique_integer([:positive])
end
In fact, avoiding unique emails like above can also have a positive
impact on the test suite performance, as it reduces contention and
wait between concurrent tests. We have heard reports where using
dynamic values for uniquely indexed columns, as we did for email
above, made a test suite run between 2x to 3x faster.
Deadlocks may happen in other circumstances. If you believe you
are hitting a scenario that has not been described here, please
report an issue so we can improve our examples. As a last resort,
you can always disable the test triggering the deadlock from
running asynchronously by setting "async: false".
"""
defmodule Connection do
@moduledoc false
if Code.ensure_loaded?(DBConnection) do
@behaviour DBConnection
end
def connect(_opts) do
raise "should never be invoked"
end
def disconnect(err, {conn_mod, state, _in_transaction?}) do
conn_mod.disconnect(err, state)
end
def checkout(state), do: proxy(:checkout, state, [])
def checkin(state), do: proxy(:checkin, state, [])
def ping(state), do: proxy(:ping, state, [])
def handle_begin(opts, {conn_mod, state, false}) do
opts = [mode: :savepoint] ++ opts
case conn_mod.handle_begin(opts, state) do
{:ok, value, state} ->
{:ok, value, {conn_mod, state, true}}
{kind, err, state} ->
{kind, err, {conn_mod, state, false}}
end
end
def handle_commit(opts, {conn_mod, state, true}) do
opts = [mode: :savepoint] ++ opts
proxy(:handle_commit, {conn_mod, state, false}, [opts])
end
def handle_rollback(opts, {conn_mod, state, _}) do
opts = [mode: :savepoint] ++ opts
proxy(:handle_rollback, {conn_mod, state, false}, [opts])
end
def handle_status(opts, state),
do: proxy(:handle_status, state, [maybe_savepoint(opts, state)])
def handle_prepare(query, opts, state),
do: proxy(:handle_prepare, state, [query, maybe_savepoint(opts, state)])
def handle_execute(query, params, opts, state),
do: proxy(:handle_execute, state, [query, params, maybe_savepoint(opts, state)])
def handle_close(query, opts, state),
do: proxy(:handle_close, state, [query, maybe_savepoint(opts, state)])
def handle_declare(query, params, opts, state),
do: proxy(:handle_declare, state, [query, params, maybe_savepoint(opts, state)])
def handle_fetch(query, cursor, opts, state),
do: proxy(:handle_fetch, state, [query, cursor, maybe_savepoint(opts, state)])
def handle_deallocate(query, cursor, opts, state),
do: proxy(:handle_deallocate, state, [query, cursor, maybe_savepoint(opts, state)])
defp maybe_savepoint(opts, {_, _, in_transaction?}) do
if not in_transaction? and Keyword.get(opts, :sandbox_subtransaction, true) do
[mode: :savepoint] ++ opts
else
opts
end
end
defp proxy(fun, {conn_mod, state, in_transaction?}, args) do
result = apply(conn_mod, fun, args ++ [state])
pos = :erlang.tuple_size(result)
:erlang.setelement(pos, result, {conn_mod, :erlang.element(pos, result), in_transaction?})
end
end
@doc """
Starts a process that will check out and own a connection, then returns that process's pid.
The process is not linked to the caller, so it is your responsibility to ensure that it will be
stopped with `stop_owner/1`. In tests, this is done in an `ExUnit.Callbacks.on_exit/2` callback:
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
:ok
end
## `start_owner!/2` vs `checkout/2`
`start_owner!/2` should be used in place of `checkout/2`.
`start_owner!/2` solves the problem of unlinked processes started in a test outliving the test process and causing ownership errors.
For example, `LiveView`'s `live(...)` test helper starts a process linked to the LiveView supervisor, not the test process.
These errors can be eliminated by having the owner of the connection be a separate process from the test process.
Outside of that scenario, `checkout/2` involves less overhead than this function and so can be preferable.
## Options
* `:shared` - if `true`, the pool runs in the shared mode. Defaults to `false`
The remaining options are passed to `checkout/2`.
"""
@doc since: "3.4.4"
@spec start_owner!(Ecto.Repo.t() | pid(), keyword()) :: pid()
def start_owner!(repo, opts \\ []) do
parent = self()
{:ok, pid} =
Agent.start(fn ->
{shared, opts} = Keyword.pop(opts, :shared, false)
:ok = checkout(repo, opts)
if shared do
:ok = mode(repo, {:shared, self()})
else
:ok = allow(repo, self(), parent)
end
end)
pid
end
@doc """
Stops an owner process started by `start_owner!/2`.
"""
@doc since: "3.4.4"
@spec stop_owner(pid()) :: :ok
def stop_owner(pid) do
GenServer.stop(pid)
end
@doc """
Sets the mode for the `repo` pool.
The modes can be:
* `:auto` - this is the default mode. When trying to use the repository,
processes can automatically checkout a connection without calling
`checkout/2` or `start_owner/2` before. This is the mode you will run
on before your test suite starts
* `:manual` - in this mode, the connection always has to be explicitly
checked before used. Other processes are allowed to use the same
connection if they are explicitly allowed via `allow/4`. You usually
set the mode to manual at the end of your `test/test_helper.exs` file.
This is also the mode you will run your async tests in
* `{:shared, pid}` - after checking out a connection in manual mode,
you can change the mode to `{:shared, pid}`, where pid is the process
that owns the connection, most often `{:shared, self()}`. This makes it
so all processes can use the same connection as the one owned by the
current process. This is the mode you will run your sync tests in
Whenever you change the mode to `:manual` or `:auto`, all existing
connections are checked in. Therefore, it is recommend to set those
modes before your test suite starts, as otherwise you will check in
connections being used in any other test running concurrently.
If successful, returns `:ok` (this is always successful for `:auto`
and `:manual` modes). It may return `:not_owner` or `:not_found`
when setting `{:shared, pid}` and the given `pid` does not own any
connection for the repo. May return `:already_shared` if another
process set the ownership mode to `{:shared, _}` and is still alive.
"""
@spec mode(Ecto.Repo.t() | pid(), :auto | :manual | {:shared, pid()}) ::
:ok | :already_shared | :not_owner | :not_found
def mode(repo, mode)
when (is_atom(repo) or is_pid(repo)) and mode in [:auto, :manual]
when (is_atom(repo) or is_pid(repo)) and elem(mode, 0) == :shared and is_pid(elem(mode, 1)) do
%{pid: pool, opts: opts} = lookup_meta!(repo)
DBConnection.Ownership.ownership_mode(pool, mode, opts)
end
@doc """
Checks a connection out for the given `repo`.
The process calling `checkout/2` will own the connection
until it calls `checkin/2` or until it crashes in which case
the connection will be automatically reclaimed by the pool.
If successful, returns `:ok`. If the caller already has a
connection, it returns `{:already, :owner | :allowed}`.
## Options
* `:sandbox` - when true the connection is wrapped in
a transaction. Defaults to true.
* `:isolation` - set the query to the given isolation level.
* `:ownership_timeout` - limits how long the connection can be
owned. Defaults to the value in your repo config in
`config/config.exs` (or preferably in `config/test.exs`), or
120000 ms if not set. The timeout exists for sanity checking
purposes, to ensure there is no connection leakage, and can
be bumped whenever necessary.
"""
@spec checkout(Ecto.Repo.t() | pid(), keyword()) :: :ok | {:already, :owner | :allowed}
def checkout(repo, opts \\ []) when is_atom(repo) or is_pid(repo) do
%{pid: pool, opts: pool_opts} = lookup_meta!(repo)
pool_opts =
if Keyword.get(opts, :sandbox, true) do
[
post_checkout: &post_checkout(&1, &2, opts),
pre_checkin: &pre_checkin(&1, &2, &3, opts)
] ++ pool_opts
else
pool_opts
end
pool_opts_overrides = Keyword.take(opts, [:ownership_timeout, :isolation_level])
pool_opts = Keyword.merge(pool_opts, pool_opts_overrides)
case DBConnection.Ownership.ownership_checkout(pool, pool_opts) do
:ok ->
if isolation = opts[:isolation] do
set_transaction_isolation_level(repo, isolation)
end
:ok
other ->
other
end
end
defp set_transaction_isolation_level(repo, isolation) do
query = "SET TRANSACTION ISOLATION LEVEL #{isolation}"
case Ecto.Adapters.SQL.query(repo, query, [], sandbox_subtransaction: false) do
{:ok, _} ->
:ok
{:error, error} ->
checkin(repo, [])
raise error
end
end
@doc """
Checks in the connection back into the sandbox pool.
"""
@spec checkin(Ecto.Repo.t() | pid()) :: :ok | :not_owner | :not_found
def checkin(repo, _opts \\ []) when is_atom(repo) or is_pid(repo) do
%{pid: pool, opts: opts} = lookup_meta!(repo)
DBConnection.Ownership.ownership_checkin(pool, opts)
end
@doc """
Allows the `allow` process to use the same connection as `parent`.
`allow` may be a PID or a locally registered name.
If the allowance is successful, this function returns `:ok`. If `allow` is already an
owner or already allowed, it returns `{:already, :owner | :allowed}`. If `parent` has not
checked out a connection from the repo, it returns `:not_found`.
"""
@spec allow(Ecto.Repo.t() | pid(), pid(), term()) ::
:ok | {:already, :owner | :allowed} | :not_found
def allow(repo, parent, allow, opts \\ []) when is_atom(repo) or is_pid(repo) do
case GenServer.whereis(allow) do
pid when is_pid(pid) ->
%{pid: pool, opts: meta_opts} = lookup_meta!(repo)
opts = Keyword.merge(meta_opts, opts)
DBConnection.Ownership.ownership_allow(pool, parent, pid, opts)
other ->
raise """
only PID or a locally registered process can be allowed to \
use the same connection as parent but the lookup returned #{inspect(other)}
"""
end
end
@doc """
Runs a function outside of the sandbox.
"""
@spec unboxed_run(Ecto.Repo.t() | pid(), (-> result)) :: result when result: var
def unboxed_run(repo, fun) when is_atom(repo) or is_pid(repo) do
checkin(repo)
checkout(repo, sandbox: false)
try do
fun.()
after
checkin(repo)
end
end
defp lookup_meta!(repo) do
%{opts: opts} =
meta =
repo
|> find_repo()
|> Ecto.Adapter.lookup_meta()
if opts[:pool] != DBConnection.Ownership do
raise """
cannot invoke sandbox operation with pool #{inspect(opts[:pool])}.
To use the SQL Sandbox, configure your repository pool as:
pool: #{inspect(__MODULE__)}
"""
end
meta
end
defp find_repo(repo) when is_atom(repo), do: repo.get_dynamic_repo()
defp find_repo(repo), do: repo
defp post_checkout(conn_mod, conn_state, opts) do
case conn_mod.handle_begin([mode: :transaction] ++ opts, conn_state) do
{:ok, _, conn_state} ->
{:ok, Connection, {conn_mod, conn_state, false}}
{:transaction, _conn_state} ->
raise """
Ecto SQL sandbox transaction cannot be started because there is already\
a transaction running.
This either means some code is starting a transaction before the sandbox\
or a connection was not appropriately rolled back after use.
"""
{_error_or_disconnect, err, conn_state} ->
{:disconnect, err, conn_mod, conn_state}
end
end
defp pre_checkin(:checkin, Connection, {conn_mod, conn_state, _in_transaction?}, opts) do
case conn_mod.handle_rollback([mode: :transaction] ++ opts, conn_state) do
{:ok, _, conn_state} ->
{:ok, conn_mod, conn_state}
{:idle, _conn_state} ->
raise """
Ecto SQL sandbox transaction was already committed/rolled back.
The sandbox works by running each test in a transaction and closing the\
transaction afterwards. However, the transaction has already terminated.\
Your test code is likely committing or rolling back transactions manually,\
either by invoking procedures or running custom SQL commands.
One option is to manually checkout a connection without a sandbox:
Ecto.Adapters.SQL.Sandbox.checkout(repo, sandbox: false)
But remember you will have to undo any database changes performed by such tests.
"""
{_error_or_disconnect, err, conn_state} ->
{:disconnect, err, conn_mod, conn_state}
end
end
defp pre_checkin(_, Connection, {conn_mod, conn_state, _in_transaction?}, _opts) do
{:ok, conn_mod, conn_state}
end
end

View File

@@ -0,0 +1,43 @@
defmodule Ecto.Adapters.SQL.Stream do
@moduledoc false
defstruct [:meta, :statement, :params, :opts]
def build(meta, statement, params, opts) do
%__MODULE__{meta: meta, statement: statement, params: params, opts: opts}
end
end
alias Ecto.Adapters.SQL.Stream
defimpl Enumerable, for: Stream do
def count(_), do: {:error, __MODULE__}
def member?(_, _), do: {:error, __MODULE__}
def slice(_), do: {:error, __MODULE__}
def reduce(stream, acc, fun) do
%Stream{meta: meta, statement: statement, params: params, opts: opts} = stream
Ecto.Adapters.SQL.reduce(meta, statement, params, opts, acc, fun)
end
end
defimpl Collectable, for: Stream do
def into(stream) do
%Stream{meta: meta, statement: statement, params: params, opts: opts} = stream
{state, fun} = Ecto.Adapters.SQL.into(meta, statement, params, opts)
{state, make_into(fun, stream)}
end
defp make_into(fun, stream) do
fn
state, :done ->
fun.(state, :done)
stream
state, acc ->
fun.(state, acc)
end
end
end

View File

@@ -0,0 +1,296 @@
defmodule Ecto.Adapters.Tds do
@moduledoc """
Adapter module for MSSQL Server using the TDS protocol.
## Options
Tds options split in different categories described
below. All options can be given via the repository
configuration.
### Connection options
* `:hostname` - Server hostname
* `:port` - Server port (default: 1433)
* `:username` - Username
* `:password` - User password
* `:database` - the database to connect to
* `:pool` - The connection pool module, may be set to `Ecto.Adapters.SQL.Sandbox`
* `:ssl` - Set to true if ssl should be used (default: false)
* `:ssl_opts` - A list of ssl options, see Erlang's `ssl` docs
* `:show_sensitive_data_on_connection_error` - show connection data and
configuration whenever there is an error attempting to connect to the
database
We also recommend developers to consult the `Tds.start_link/1` documentation
for a complete list of all supported options for driver.
### Storage options
* `:collation` - the database collation. Used during database creation but
it is ignored later
If you need collation other than Latin1, add `tds_encoding` as dependency to
your project `mix.exs` file then amend `config/config.ex` by adding:
config :tds, :text_encoder, Tds.Encoding
This should give you extended set of most encoding. For complete list check
`Tds.Encoding` [documentation](https://hexdocs.pm/tds_encoding).
### After connect flags
After connecting to MSSQL server, TDS will check if there are any flags set in
connection options that should affect connection session behaviour. All flags are
MSSQL standard *SET* options. The following flags are currently supported:
* `:set_language` - sets session language (consult stored procedure output
`exec sp_helplanguage` for valid values)
* `:set_datefirst` - number in range 1..7
* `:set_dateformat` - atom, one of `:mdy | :dmy | :ymd | :ydm | :myd | :dym`
* `:set_deadlock_priority` - atom, one of `:low | :high | :normal | -10..10`
* `:set_lock_timeout` - number in milliseconds > 0
* `:set_remote_proc_transactions` - atom, one of `:on | :off`
* `:set_implicit_transactions` - atom, one of `:on | :off`
* `:set_allow_snapshot_isolation` - atom, one of `:on | :off`
(required if `Repo.transaction(fn -> ... end, isolation_level: :snapshot)` is used)
* `:set_read_committed_snapshot` - atom, one of `:on | :off`
## Limitations
### UUIDs
MSSQL server has slightly different binary storage format for UUIDs (`uniqueidentifier`).
If you use `:binary_id`, the proper choice is made. Otherwise you must use the `Tds.Ecto.UUID`
type. Avoid using `Ecto.UUID` since it may cause unpredictable application behaviour.
### SQL `Char`, `VarChar` and `Text` types
When working with binaries and strings,there are some limitations you should be aware of:
- Strings that should be stored in mentioned sql types must be encoded to column
codepage (defined in collation). If collation is different than database collation,
it is not possible to store correct value into database since the connection
respects the database collation. Ecto does not provide way to override parameter
codepage.
- If you need other than Latin1 or other than your database default collation, as
mentioned in "Storage Options" section, then manually encode strings using
`Tds.Encoding.encode/2` into desired codepage and then tag parameter as `:binary`.
Please be aware that queries that use this approach in where clauses can be 10x slower
due increased logical reads in database.
- You can't store VarChar codepoints encoded in one collation/codepage to column that
is encoded in different collation/codepage. You will always get wrong result. This is
not adapter or driver limitation but rather how string encoding works for single byte
encoded strings in MSSQL server. Don't be confused if you are always seeing latin1 chars,
they are simply in each codepoint table.
In particular, if a field has the type `:text`, only raw binaries will be allowed.
To avoid above limitations always use `:string` (NVarChar) type for text if possible.
If you really need to use VarChar's column type, you can use the `Tds.Ecto.VarChar`
Ecto type.
### JSON support
Even though the adapter will convert `:map` fields into JSON back and forth,
actual value is stored in NVarChar column.
### Query hints and table hints
MSSQL supports both query hints and table hints: https://docs.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query
For Ecto compatibility, the query hints must be given via the `lock` option, and they
will be translated to MSSQL's "OPTION". If you need to pass multiple options, you
can separate them by comma:
from query, lock: "HASH GROUP, FAST 10"
Table hints are specified as a list alongside a `from` or `join`:
from query, hints: ["INDEX (IX_Employee_ManagerID)"]
The `:migration_lock` will be treated as a table hint and defaults to "UPDLOCK".
### Multi Repo calls in transactions
To avoid deadlocks in your app, we exposed `:isolation_level` repo transaction option.
This will tell to SQL Server Transaction Manager how to begin transaction.
By default, if this option is omitted, isolation level is set to `:read_committed`.
Any attempt to manually set the transaction isolation via queries, such as
Ecto.Adapter.SQL.query("SET TRANSACTION ISOLATION LEVEL XYZ")
will fail once explicit transaction is started using `c:Ecto.Repo.transaction/2`
and reset back to :read_committed.
There is `Ecto.Query.lock/3` function can help by setting it to `WITH(NOLOCK)`.
This should allow you to do eventually consistent reads and avoid locks on given
table if you don't need to write to database.
NOTE: after explicit transaction ends (commit or rollback) implicit transactions
will run as READ_COMMITTED.
"""
use Ecto.Adapters.SQL,
driver: :tds
require Logger
require Ecto.Query
@behaviour Ecto.Adapter.Storage
@doc false
def autogenerate(:binary_id), do: Tds.Ecto.UUID.bingenerate()
def autogenerate(:embed_id), do: Tds.Ecto.UUID.generate()
def autogenerate(type), do: super(type)
@doc false
@impl true
def loaders({:map, _}, type), do: [&json_decode/1, &Ecto.Type.embedded_load(type, &1, :json)]
def loaders(:map, type), do: [&json_decode/1, type]
def loaders(:boolean, type), do: [&bool_decode/1, type]
def loaders(:binary_id, type), do: [Tds.Ecto.UUID, type]
def loaders(_, type), do: [type]
@impl true
def dumpers({:map, _}, type), do: [&Ecto.Type.embedded_dump(type, &1, :json)]
def dumpers(:binary_id, type), do: [type, Tds.Ecto.UUID]
def dumpers(_, type), do: [type]
defp bool_decode(<<0>>), do: {:ok, false}
defp bool_decode(<<1>>), do: {:ok, true}
defp bool_decode(0), do: {:ok, false}
defp bool_decode(1), do: {:ok, true}
defp bool_decode(x), do: {:ok, x}
defp json_decode(x) when is_binary(x), do: {:ok, Tds.json_library().decode!(x)}
defp json_decode(x), do: {:ok, x}
# Storage API
@doc false
@impl true
def storage_up(opts) do
database = Keyword.fetch!(opts, :database)
command =
~s(CREATE DATABASE [#{database}])
|> concat_if(opts[:collation], &"COLLATE=#{&1}")
case run_query(Keyword.put(opts, :database, "master"), command) do
{:ok, _} ->
:ok
{:error, %{mssql: %{number: 1801}}} ->
{:error, :already_up}
{:error, error} ->
{:error, Exception.message(error)}
end
end
defp concat_if(content, nil, _fun), do: content
defp concat_if(content, value, fun), do: content <> " " <> fun.(value)
@doc false
@impl true
def storage_down(opts) do
database = Keyword.fetch!(opts, :database)
case run_query(Keyword.put(opts, :database, "master"), "DROP DATABASE [#{database}]") do
{:ok, _} ->
:ok
{:error, %{mssql: %{number: 3701}}} ->
{:error, :already_down}
{:error, error} ->
{:error, Exception.message(error)}
end
end
@impl Ecto.Adapter.Storage
def storage_status(opts) do
database = Keyword.fetch!(opts, :database)
opts = Keyword.put(opts, :database, "master")
check_database_query =
"SELECT [name] FROM [master].[sys].[databases] WHERE [name] = '#{database}'"
case run_query(opts, check_database_query) do
{:ok, %{num_rows: 0}} -> :down
{:ok, %{num_rows: _}} -> :up
other -> {:error, other}
end
end
defp run_query(opts, sql_command) do
{:ok, _} = Application.ensure_all_started(:ecto_sql)
{:ok, _} = Application.ensure_all_started(:tds)
timeout = Keyword.get(opts, :timeout, 15_000)
opts =
opts
|> Keyword.drop([:name, :log, :pool, :pool_size])
|> Keyword.put(:backoff_type, :stop)
|> Keyword.put(:max_restarts, 0)
{:ok, pid} = Task.Supervisor.start_link()
task =
Task.Supervisor.async_nolink(pid, fn ->
{:ok, conn} = Tds.start_link(opts)
value = Ecto.Adapters.Tds.Connection.execute(conn, sql_command, [], opts)
GenServer.stop(conn)
value
end)
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, {:ok, result}} ->
{:ok, result}
{:ok, {:error, error}} ->
{:error, error}
{:exit, {%{__struct__: struct} = error, _}}
when struct in [Tds.Error, DBConnection.Error] ->
{:error, error}
{:exit, reason} ->
{:error, RuntimeError.exception(Exception.format_exit(reason))}
nil ->
{:error, RuntimeError.exception("command timed out")}
end
end
@impl true
def supports_ddl_transaction? do
true
end
@impl true
def lock_for_migrations(meta, opts, fun) do
%{opts: adapter_opts, repo: repo} = meta
if Keyword.fetch(adapter_opts, :pool_size) == {:ok, 1} do
Ecto.Adapters.SQL.raise_migration_pool_size_error()
end
opts = Keyword.merge(opts, timeout: :infinity, telemetry_options: [schema_migration: true])
{:ok, result} =
transaction(meta, opts, fn ->
query =
"exec sp_getapplock @Resource = 'ecto_#{inspect(repo)}', @LockMode = 'Exclusive', @LockOwner = 'Transaction', @LockTimeout = -1"
Ecto.Adapters.SQL.query!(meta, query, [], opts)
fun.()
end)
result
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,293 @@
if Code.ensure_loaded?(Tds) do
defmodule Tds.Ecto.UUID do
@moduledoc """
A TDS adapter type for UUIDs strings.
If you are using Tds adapter and UUIDs in your project, instead of `Ecto.UUID`
you should use Tds.Ecto.UUID to generate correct bytes that should be stored
in database.
"""
use Ecto.Type
@typedoc """
A hex-encoded UUID string.
"""
@type t :: <<_::288>>
@typedoc """
A raw binary representation of a UUID.
"""
@type raw :: <<_::128>>
@doc false
@impl true
def type(), do: :uuid
@doc """
Casts to UUID.
"""
@impl true
@spec cast(t | raw | any) :: {:ok, t} | :error
def cast(
<<a1, a2, a3, a4, a5, a6, a7, a8, ?-, b1, b2, b3, b4, ?-, c1, c2, c3, c4, ?-, d1, d2,
d3, d4, ?-, e1, e2, e3, e4, e5, e6, e7, e8, e9, e10, e11, e12>>
) do
<<c(a1), c(a2), c(a3), c(a4), c(a5), c(a6), c(a7), c(a8), ?-, c(b1), c(b2), c(b3), c(b4),
?-, c(c1), c(c2), c(c3), c(c4), ?-, c(d1), c(d2), c(d3), c(d4), ?-, c(e1), c(e2), c(e3),
c(e4), c(e5), c(e6), c(e7), c(e8), c(e9), c(e10), c(e11), c(e12)>>
catch
:error -> :error
else
casted -> {:ok, casted}
end
def cast(<<bin::binary-size(16)>>), do: encode(bin)
def cast(_), do: :error
@doc """
Same as `cast/1` but raises `Ecto.CastError` on invalid arguments.
"""
def cast!(value) do
case cast(value) do
{:ok, uuid} -> uuid
:error -> raise Ecto.CastError, type: __MODULE__, value: value
end
end
@compile {:inline, c: 1}
defp c(?0), do: ?0
defp c(?1), do: ?1
defp c(?2), do: ?2
defp c(?3), do: ?3
defp c(?4), do: ?4
defp c(?5), do: ?5
defp c(?6), do: ?6
defp c(?7), do: ?7
defp c(?8), do: ?8
defp c(?9), do: ?9
defp c(?A), do: ?a
defp c(?B), do: ?b
defp c(?C), do: ?c
defp c(?D), do: ?d
defp c(?E), do: ?e
defp c(?F), do: ?f
defp c(?a), do: ?a
defp c(?b), do: ?b
defp c(?c), do: ?c
defp c(?d), do: ?d
defp c(?e), do: ?e
defp c(?f), do: ?f
defp c(_), do: throw(:error)
@doc """
Converts a string representing a UUID into a binary.
"""
@impl true
@spec dump(t | any) :: {:ok, raw} | :error
def dump(
<<a1, a2, a3, a4, a5, a6, a7, a8, ?-, b1, b2, b3, b4, ?-, c1, c2, c3, c4, ?-, d1, d2,
d3, d4, ?-, e1, e2, e3, e4, e5, e6, e7, e8, e9, e10, e11, e12>>
) do
try do
<<d(a7)::4, d(a8)::4, d(a5)::4, d(a6)::4, d(a3)::4, d(a4)::4, d(a1)::4, d(a2)::4,
d(b3)::4, d(b4)::4, d(b1)::4, d(b2)::4, d(c3)::4, d(c4)::4, d(c1)::4, d(c2)::4,
d(d1)::4, d(d2)::4, d(d3)::4, d(d4)::4, d(e1)::4, d(e2)::4, d(e3)::4, d(e4)::4,
d(e5)::4, d(e6)::4, d(e7)::4, d(e8)::4, d(e9)::4, d(e10)::4, d(e11)::4, d(e12)::4>>
catch
:error -> :error
else
binary ->
{:ok, binary}
end
end
def dump(_), do: :error
def dump!(value) do
case dump(value) do
{:ok, binary} -> binary
:error -> raise ArgumentError, "Invalid uuid value #{inspect(value)}"
end
end
@compile {:inline, d: 1}
defp d(?0), do: 0
defp d(?1), do: 1
defp d(?2), do: 2
defp d(?3), do: 3
defp d(?4), do: 4
defp d(?5), do: 5
defp d(?6), do: 6
defp d(?7), do: 7
defp d(?8), do: 8
defp d(?9), do: 9
defp d(?A), do: 10
defp d(?B), do: 11
defp d(?C), do: 12
defp d(?D), do: 13
defp d(?E), do: 14
defp d(?F), do: 15
defp d(?a), do: 10
defp d(?b), do: 11
defp d(?c), do: 12
defp d(?d), do: 13
defp d(?e), do: 14
defp d(?f), do: 15
defp d(_), do: throw(:error)
@doc """
Converts a binary UUID into a string.
"""
@impl true
@spec load(raw | any) :: {:ok, t} | :error
def load(<<_::128>> = uuid) do
encode(uuid)
end
def load(<<_::64, ?-, _::32, ?-, _::32, ?-, _::32, ?-, _::96>> = string) do
raise ArgumentError,
"trying to load string UUID as Tds.Ecto.UUID: #{inspect(string)}. " <>
"Maybe you wanted to declare :uuid as your database field?"
end
def load(_), do: :error
@doc """
Generates a version 4 (random) UUID.
"""
@spec generate() :: t
def generate do
{:ok, uuid} = encode(bingenerate())
uuid
end
@doc """
Generates a version 4 (random) UUID in the binary format.
"""
@spec bingenerate() :: raw
def bingenerate do
<<u0::56, u1::36, u2::28>> = :crypto.strong_rand_bytes(15)
<<u0::56, 4::4, u1::36, 2::4, u2::28>>
end
# Callback invoked by autogenerate fields.
@impl true
def autogenerate, do: generate()
defp encode(
<<a1::4, a2::4, a3::4, a4::4, a5::4, a6::4, a7::4, a8::4, b1::4, b2::4, b3::4, b4::4,
c1::4, c2::4, c3::4, c4::4, d1::4, d2::4, d3::4, d4::4, e1::4, e2::4, e3::4, e4::4,
e5::4, e6::4, e7::4, e8::4, e9::4, e10::4, e11::4, e12::4>>
) do
<<e(a7), e(a8), e(a5), e(a6), e(a3), e(a4), e(a1), e(a2), ?-, e(b3), e(b4), e(b1), e(b2),
?-, e(c3), e(c4), e(c1), e(c2), ?-, e(d1), e(d2), e(d3), e(d4), ?-, e(e1), e(e2), e(e3),
e(e4), e(e5), e(e6), e(e7), e(e8), e(e9), e(e10), e(e11), e(e12)>>
catch
:error -> :error
else
encoded -> {:ok, encoded}
end
@compile {:inline, e: 1}
defp e(0), do: ?0
defp e(1), do: ?1
defp e(2), do: ?2
defp e(3), do: ?3
defp e(4), do: ?4
defp e(5), do: ?5
defp e(6), do: ?6
defp e(7), do: ?7
defp e(8), do: ?8
defp e(9), do: ?9
defp e(10), do: ?a
defp e(11), do: ?b
defp e(12), do: ?c
defp e(13), do: ?d
defp e(14), do: ?e
defp e(15), do: ?f
end
defmodule Tds.Ecto.VarChar do
@moduledoc """
A Tds adapter Ecto Type that wraps erlang string into tuple so TDS driver
can understand if erlang string should be encoded as NVarChar or Varchar.
Due to some limitations in Ecto and Tds driver, it is not possible to
support collations other than the one set on connection during login.
Please be aware of this limitation if you plan to store varchar values in
your database using Ecto since you will probably lose some codepoints in
the value during encoding. Instead use `tds_encoding` library and first
encode value and then annotate it as `:binary` by calling `Ecto.Query.API.type/2`
in your query. This way all codepoints will be properly preserved during
insert to database.
"""
use Ecto.Type
@typedoc """
An erlang string
"""
@type t :: String.t()
@typedoc """
A value annotated as varchar.
"""
@type varchar :: {String.t(), :varchar}
@doc false
@impl true
def type(), do: :varchar
@doc """
Casts to string.
"""
@spec cast(t | varchar | any) :: {:ok, t} | :error
@impl true
def cast({value, :varchar}) do
# In case we get already dumped value
{:ok, value}
end
def cast(value) when is_binary(value) do
{:ok, value}
end
def cast(_), do: :error
@doc """
Same as `cast/1` but raises `Ecto.CastError` on invalid arguments.
"""
@spec cast!(t | varchar | any) :: t
def cast!(value) do
case cast(value) do
{:ok, uuid} -> uuid
:error -> raise Ecto.CastError, type: __MODULE__, value: value
end
end
@doc """
Loads the DB type as is.
"""
@impl true
@spec load(t | any) :: {:ok, t} | :error
def load(value) do
{:ok, value}
end
@doc """
Converts a string representing a VarChar into a tuple `{value, :varchar}`.
Returns `:error` if value is not binary.
"""
@impl true
@spec dump(t | any) :: {:ok, varchar} | :error
def dump(value) when is_binary(value) do
{:ok, {value, :varchar}}
end
def dump(_), do: :error
end
end