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,36 @@
locals_without_parens = [
add: 2,
add: 3,
add_if_not_exists: 2,
add_if_not_exists: 3,
alter: 2,
create: 1,
create: 2,
create_if_not_exists: 1,
create_if_not_exists: 2,
drop: 1,
drop: 2,
drop_if_exists: 1,
drop_if_exists: 2,
execute: 1,
execute: 2,
modify: 2,
modify: 3,
remove: 1,
remove: 2,
remove: 3,
remove_if_exists: 1,
remove_if_exists: 2,
rename: 2,
rename: 3,
timestamps: 1
]
[
import_deps: [:ecto],
locals_without_parens: locals_without_parens,
export: [
locals_without_parens: locals_without_parens
],
inputs: ["{lib,test}/**/*.{ex,exs}"]
]

BIN
phoenix/deps/ecto_sql/.hex Normal file

Binary file not shown.

View File

@@ -0,0 +1,579 @@
# Changelog for v3.x
## v3.13.5 (2026-03-03)
* [postgrex] Map `:restrict_violation` to `:foreign_key` constraint (required by PostgreSQL 18)
## v3.13.4 (2025-12-27)
### Bug fixes
* [mysql] Do not crash `mix ecto.load` with large dumped databases
## v3.13.3 (2025-12-08)
### Enhancements
* [sql] Tag generated functions as `:generated`
* [sql] Add `:wrap_in_transaction` option to explain
### Bug fixes
* [mysql] Fix `structure_load/2` for MySQL 9.4+
## v3.13.2 (2025-06-24)
### Enhancements
* [sandbox] Allow passing through opts in `Ecto.Adapters.SQL.Sandbox.allow/4` calls
* [sql] Add support for `ON DELETE SET DEFAULT`
### Bug fixes
* [postgres] Fix nested array generated time columns
## v3.13.1 (2025-06-20)
### Bug fixes
* [postgres] Fix nested array generated columns
## v3.13.0 (2025-06-18)
### Enhancements
* [Ecto.Migration] Add support for index directions
* [sql] Support `:log_stacktrace_mfa` for filtering or modifying stacktrace-derived info in query logs
* [mysql] Support arrays using JSON for MariaDB
* [mysql] Allow to specify `:prepare` per operation
* [postgres] Add support for collations in Postgres
* [postgres] Allow source fields in `json_extract_path`
## v3.12.1 (2024-10-07)
### Enhancements
* [sql] Support `:pool_count` option
## v3.12.0 (2024-08-12)
### Enhancements
* [Ecto.Migration] Add `Ecto.Migration.remove_if_exists/1`
* [Ecto.Migrator] Warn for migration files that end in `.ex`
* [sql] Support for subqueries in order_bys and group_bys
* [mysql] Add check constraints for MySQL
* [postgres] Add native bitstring support to Postgres
* [postgres] Add support for `:duration` type
* [postgres] Add `:plan` explain option for Postgres
* [tds] Allow passing `%Tds.Parameter` structs as params for named parameter usage in `query`
### Bug fix
* [mysql] Type cast of integers in MySQL should use signed integers
## v3.11.3 (2024-06-13)
### Enhancements
* [mysql] Relax `myxql` dependency
## v3.11.2 (2024-05-18)
### Enhancements
* [postgres] Relax `postgrex` dependency
## v3.11.1 (2023-12-07)
### Enhancements
* [Ecto.Migration] Add `:generated` option to columns
* [Ecto.Migration] Add index storage parameters (via :options) for Postgres
### Bug fixes
* [Ecto.Migration] Support `:prefix` on index rename
* [Ecto.Migrator] Stop runner if migration fails
## v3.11.0 (2023-11-14)
### Enhancements
* [mix ecto.migrate] Add `--log-level` to ecto.migrate
* [mix ecto.rollback] Add `--log-level` to ecto.rollback
* [sql] Support fragment splicing
* [sql] Support data-modifying CTEs
* [sql] Add source to insert_ll, insert, update, and delete telemetry events
* [tds] Include `exec` before stored procedure for TDS (for earlier SQLServer versions)
### Bug fixes
* [mix ecto.migrate] Read existing dynamic repo in migrations
* [mix ecto.migrate] Don't add primary key on remove migration
## v3.10.2 (2023-08-21)
### Enhancements
* [migrations] Handle `from: {reference, opts}` in FK migrations
* [mysql] Support MariaDB versioned tables
### Bug fixes
* [migrations] Don't add comment to removed columns
* [migrations] Ensure module is loaded before checking for migration
* [mysql] Fix for casting boolean values in MySQL
## v3.10.1 (2023-04-11)
### Enhancements
* [postgres] Allow Postgrex v0.17.x
## v3.10.0 (2023-04-10)
### Enhancements
* [Ecto.Migrator] Allow running the migrator in your supervision tree
* [Ecto.Migrator] Allow renaming an index
* [Ecto.Migrator] Add `execute_file/1` and `execute_file/2`
* [mix ecto.dump] Support dumping multiple prefixes on PostgreSQL and MySQL
* [mysql] Improve constraint matching support on alternative implementations
* [postgres] Allow `CASCADE` when dropping a constraint on postgres
### Bug fixes
* [mix ecto.load] Suppress query logs in mix ecto.load when quiet flag is given
## v3.9.2 (2022-12-20)
### Enhancements
* [migrator] Raise if target version in `to`/`exclusive_to` is not an integer
* [mysql] Add support for cross lateral joins
* [postgres] Add support for cross lateral joins
* [postgres] Add support for materialized CTEs
* [telemetry] Send `cast_params` metadata to telemetry events
## v3.9.1 (2022-11-18)
### Enhancements
* [mysql] Support `:format` option on `explain`
* [postgres] Permit outer joins when using `update_all`
* [sql] Add support for `ONLY` in index creation
### Bug fixes
* [mysql] Ensure locks are quoted
* [mysql] Do not crash on `mix ecto.drop` when the database is unreachable
* [postgres] Fix empty array compare in PostgreSQL
* [sql] Allow function sources whose name begins with 'select'
## v3.9.0 (2022-09-27)
### Enhancements
* [migrations] Support `primary_key` configuration options in `table`
* [migrations] Add `:nulls_distinct` option for unique indexes
* [postgres] Support the use of advisory locks for migrations
* [sql] Add `dump_cmd` to `postgrex` and `myxql` adapters
* [sql] Log human-readable UUIDs by using pre-dumped query parameters
* [sql] Support select aliases from `selected_as/1` and `selected_as/2`
* [telemetry] Emit `schema_migration: true` under `telemetry_options`
## v3.8.3 (2022-06-04)
### Enhancements
* [sql] Implement `literal/1` support in fragments
## v3.8.2 (2022-05-18)
### Bug fixes
* [postgres] Fix possible breaking change on `json_extract_path` for boolean values introduced in v3.8.0
* [sql] Colorize stacktrace and use `:` before printing line number
## v3.8.1 (2022-04-29)
### Bug fixes
* [mysql] Raise on a subquery with parameter on MySQL join
* [sql] Do not invoke dynamic repositories in direct `Ecto.Adapters.SQL` operations
## v3.8.0 (2022-04-26)
### Enhancements
* [migrations] Support `--to-exclusive` in `mix ecto.migrate` and `mix ecto.rollback`
* [mysql] Add `:comment` support on MySQL migrations
* [postgres] Support `:prepare` option per operation
* [postgres] Optimize `json_extract_path` comparisons in PostgreSQL
* [sql] Optionally log last known call, publish stacktrace in telemetry
* [telemetry] Include `:repo` option in telemetry events
### Bug fixes
* [sql] Ensure `:timeout` option is respected in `Ecto.Adapters.SQL.explain/3`
## v3.7.2 (2022-01-23)
### Enhancements
* [adapters] Support latest `myxql` and `postgrex`
## v3.7.1 (2021-10-12)
### Enhancements
* [migrations] Add `:cascade` option to `drop`
* [migrations] Support `--prefix` in `mix ecto.migrations`
* [migrations] Add `--log-migrator-sql` and `--log-migrations-sql`
* [mysql] Cache more insert/update queries and allow `:cache_statement` to be set
* [mssql] Support more recent tds versions
### Bug fixes
* [migrations] Consider the database prefix when locking tables
## v3.7.0 (2021-08-19)
### Enhancements
* [mysql] Support lateral joins
### Bug fixes
* [sql] Fix CTE subqueries not finding parent bindings
## v3.6.2 (2021-05-28)
### Bug fixes
* [migration] Improve error message on invalid migration type
* [postgres] Avoid duplicate order_by with distinct
* [sql] Implement new checked_out? callback required by latest Ecto
## v3.6.1 (2021-04-12)
### Bug fixes
* [migrations] Ensure migration_source option is respected in PostgreSQL adapter
## v3.6.0 (2021-04-03)
### Bug fixes
* [migrations] Fix a bug where the migration lock would not apply on the first migration (when the schema migrations table is empty). This fix changes how migration tables are locked, therefore let us know of any possible regressions in your workflow
### Enhancements
* [migrations] Allow generating migrations from within umbrella app
* [postgres] Add `:format` option to PostgreSQL explain
* [postgres] Support `:socket_dir` connection option when using `mix ecto.load` or `mix ecto.dump`
* [sandbox] Support locally registered processes in `allow/3`
* [storage] Do not fail `storage_up` if the user has access to an already-created database
* [tds] Support for `:inner_lateral` and `:left_lateral`
## v3.5.4 (2020-01-20)
### Enhancements
* [mysql] Support defaults for JSON columns
* [postgres] Allow Postgrex v1.0
## v3.5.3 (2020-10-27)
### Enhancements
* [migrations] Pass `:schema_migration` option to repo operations for `prepare_query` checks
* [psql] Support `:force_drop` configuration to force a DB to be dropped
## v3.5.2 (2020-10-24)
### Enhancements
* [migrations] Support `:with` option in `references` for composite foreign keys
* [migrations] Support `:match` option in `references`
* [tds] Support TDS 3-part and 4-part prefixes
## v3.5.1 (2020-10-12)
### Enhancements
* [tds] Support explain plan for the TDS adapter
### Bug fix
* [migrations] Reload all migrations once the lock is free to avoid running the same migration more than once
* [query] Support nested subqueries
## v3.5.0 (2020-10-03)
### Enhancements
* [migrations] Add option to skip schema migrations table checks
* [migrations] Add `:migration_repo` configuration to allow a different repository to host the schema migrations
* [migrations] Support `validate: false` on references and constraints
* [migrations] Accept `:migration_primary_key` as false and add `:migration_foreign_key` repo config
* [postgres] Support for `:identity` key types in Postgres 10 or later
* [postgres] Use IF NOT EXIST when creating index with `create_if_not_exists`, this requires PG 9.5+ or later
* [repo] Support `Repo.explain(:all | :update_all | :delete_all, query)` for Ecto adapters
* [sandbox] Allow for dynamic repos to be checked out in sandbox
### Bug fixes
* [migrations] Flush migration commands before executing `before_commit` callback
* [migrations] Do not swallow errors when migration lock is disabled
## v3.4.5 (2020-07-05)
### Bug fixes
* [ecto] Fix warnings on Elixir v1.11
* [migrations] Migration prefix should have higher preference than `default_options`
## v3.4.4 (2020-05-19)
### Enhancements
* [sandbox] Add `Ecto.Adapters.SQL.start_owner!/2` and `Ecto.Adapters.SQL.stop_owner/1`
* [myxql] Decode BIT columns when using MyXQL and `:boolean` type
* [migrations] Use one line per migration in the schema dump
## v3.4.3 (2020-04-27)
### Bug fixes
* [ecto] Support `as` and `parent_as` from Ecto v3.4.3+
* [ecto] Support `x in subquery(query)` from Ecto v3.4.3+
## v3.4.2 (2020-04-02)
### Bug fixes
* [myxql] A binary with size should be a varbinary
* [mssql] A binary without size should be a varbinary(max)
## v3.4.1 (2020-03-25)
### Bug fixes
* [myxql] Assume the reference does not change in MyXQL and prepare for v0.4.0
## v3.4.0 (2020-03-24)
### Enhancements
* [adapters] Support Ecto's v3.4 `json_extract_path/2`
* [migrations] Support multiple migration paths to be given with `--migration-path`
* [mssql] Add built-in support to MSSQL via the TDS adapter
* [repo] Support custom options on telemetry
## v3.3.4 (2020-02-14)
### Enhancements
* [adapters] Support fragments in locks
* [migration] Add `:include` option to support covering indexes
## v3.3.3 (2020-01-28)
### Enhancements
* [myxql] Allow not setting the encoding when creating a database
### Bug fixes
* [myxql] Removing prefixed table name from constraints on latest MySQL versions
* [sql] Fix precedence of `is_nil` when inside a comparison operator
## v3.3.2 (2019-12-15)
### Bug fixes
* [adapters] Start StorageSupervisor before using it
## v3.3.1 (2019-12-15)
### Bug fixes
* [adapters] Do not leak PIDs on storage commands
* [migrations] Use :migration_primary_key in create/1
## v3.3.0 (2019-12-11)
### Enhancements
* [ecto] Upgrade and support Ecto v3.3
* [repo] Include `:idle_time` on telemetry measurements
* [migration] Support anonymous functions in `Ecto.Migration.execute/2`
### Bug fixes
* [migration] Ensure that flush() will raise on rollback if called from `change/0`
## v3.2.2 (2019-11-25)
### Enhancements
* [mysql] Support myxql v0.3
## v3.2.1 (2019-11-02)
### Enhancements
* [migration] Support anonymous functions in execute
### Bug fixes
* [mix ecto.create] Change default charset in MyXQL to utf8mb4
## v3.2.0 (2019-09-07)
This new version requires Elixir v1.6+. Note also the previously soft-deprecated `Ecto.Adapters.MySQL` has been removed in favor of `Ecto.Adapters.MyXQL`. We announced the intent to remove `Ecto.Adapters.MySQL` back in v3.0 and `Ecto.Adapters.MyXQL` has been tested since then and ready for prime time since v3.1.
### Enhancements
* [sql] Use `get_dynamic_repo` on SQL-specific functions
* [sql] Respect `Ecto.Type.embed_as/2` choice when loading/dumping embeds (Ecto 3.2+ compat)
* [sql] Support CTE expressions (Ecto 3.2+ compat)
### Bug fixes
* [sql] Fix generated "COMMENT ON INDEX" for PostgreSQL
## v3.1.6 (2019-06-27)
### Enhancements
* [sql] Set `cache_statement` for `insert_all`
## v3.1.5 (2019-06-13)
### Enhancements
* [migration] Add `@disable_migration_lock` to be better handle concurrent indexes
* [mysql] Set `cache_statement` for inserts
### Deprecations
* [mysql] Deprecate Ecto.Adapters.MySQL
## v3.1.4 (2019-05-28)
### Enhancements
* [migrator] Print warning message if concurrent indexes are used with migration lock
## v3.1.3 (2019-05-19)
### Enhancements
* [migrator] Add `--migrations-path` to ecto.migrate/ecto.rollback/ecto.migrations Mix tasks
### Bug fixes
* [migrator] Make sure an unboxed run is performed when running migrations with the ownership pool
## v3.1.2 (2019-05-11)
### Enhancements
* [migrator] Add `Ecto.Migrator.with_repo/2` to start repo and apps
* [mix] Add `--skip-if-loaded` for `ecto.load`
* [sql] Add `Ecto.Adapters.SQL.table_exists?/2`
## v3.1.1 (2019-04-16)
### Bug fixes
* [repo] Fix backwards incompatible change in Telemetry metadata
## v3.1.0 (2019-04-02)
v3.1 requires Elixir v1.5+.
### Enhancements
* [mysql] Introduce Ecto.Adapters.MyXQL as an alternative library for MySQL
* [migrations] Run all migrations in subdirectories
* [repo] Update to Telemetry v0.4.0 (note the measurements value differ from previous versions)
### Bug fixes
* [sandbox] Respect `:ownership_timeout` repo configuration on SQL Sandbox
* [migrations] Commit and relock after every migration to avoid leaving the DB in an inconsistent state under certain failures
### Backwards incompatible changes
* [migrations] If you are creating indexes concurrently, you need to disable the migration lock: `config :app, App.Repo, migration_lock: nil`. This will migrations behave the same way as they did in Ecto 2.0.
## v3.0.5 (2019-02-05)
### Enhancements
* [repo] Add `:repo` and `:type` keys to telemetry events
* [migrations] Add `:add_if_not_exists` and `:remove_if_exists` to columns in migrations
### Bug fixes
* [migrations] Load all migrations before running them
* [sandbox] Include `:queue_target` and `:queue_interval` in SQL Sandbox checkout
## v3.0.4 (2018-12-31)
### Enhancements
* [repo] Bump telemetry dependency
* [migrations] Perform strict argument parsing in `ecto.migrate`, `ecto.rollback`, `ecto.load` and `ecto.dump`
### Bug fixes
* [migrations] Do not log migration versions query
### Deprecations
* [repo] `Telemetry.attach/5` and `Telemetry.attach_many/5` are deprecated in favor of `:telemetry.attach/5` and `:telemetry.attach_many/5`
## v3.0.3 (2018-11-29)
### Enhancements
* [migration] Support `after_begin` and `before_commit` migration callbacks
* [migration] Add `:prefix` option to `references/2`
### Bug fixes
* [migration] Do not start a transaction for migrated versions if there is no `:migration_lock`
* [migration] Fix removing an reference column inside alter table
* [migration] Warn on removed `:pool_timeout` option
## v3.0.2 (2018-11-20)
### Enhancements
* [query] Support `Ecto.Query` in `insert_all` values
* [migration] Add `Ecto.Migration.repo/0`
## v3.0.1 (2018-11-17)
### Enhancements
* [migrations] Support `drop_if_exists` for constraints
### Bug fixes
* [migrations] Only commit migration transaction if migration can be inserted into the DB
* [migrations] Do not run migrations from `_build` when using Mix
* [migrations] Improve errors when checking in already committed sandboxes
* [mysql] Do not pass nil for `--user` to mysqldump
* [package] Require Ecto 3.0.2 with bug fixes
* [package] Require Mariaex 0.9.1 which fixes a bug when used with Ecto 3.0.2
* [sandbox] Raise when using sandbox on non-sandbox pools
## v3.0.0 (2018-10-29)
* Initial release

View File

@@ -0,0 +1,73 @@
Ecto SQL
=========
[![Build Status](https://github.com/elixir-ecto/ecto_sql/workflows/CI/badge.svg)](https://github.com/elixir-ecto/ecto_sql/actions)
Ecto SQL ([documentation](https://hexdocs.pm/ecto_sql)) provides building blocks for writing SQL adapters for Ecto. It features:
* The Ecto.Adapters.SQL module as an entry point for all SQL-based adapters
* Default implementations for Postgres (Ecto.Adapters.Postgres), MySQL (Ecto.Adapters.MyXQL), and MSSQL (Ecto.Adapters.Tds)
* A test sandbox (Ecto.Adapters.SQL.Sandbox) that concurrently runs database tests inside transactions
* Support for database migrations via Mix tasks
To learn more about getting started, [see the Ecto repository](https://github.com/elixir-ecto/ecto).
## Running tests
Clone the repo and fetch its dependencies:
$ git clone https://github.com/elixir-ecto/ecto_sql.git
$ cd ecto_sql
$ mix deps.get
$ mix test
In case you are modifying Ecto and EctoSQL at the same time, you can configure EctoSQL to use an Ecto version from your machine by running:
$ ECTO_PATH=../ecto mix test.all
### Running integration tests
The command above will run unit tests. EctoSQL also has a suite of integration tests for its built-in adapters: `pg`, `myxql` and `tds`. If you are changing logic specific to a database, we recommend running its respective integration test suite as well. Doing so requires you to have the database available locally. MySQL and PostgreSQL can be installed directly on most systems. For MSSQL, you may need to run it as a Docker image:
docker run -d -p 1433:1433 --name mssql -e 'ACCEPT_EULA=Y' -e 'MSSQL_SA_PASSWORD=some!Password' mcr.microsoft.com/mssql/server:2017-latest
Once the database is running, you can run tests against a specific Ecto adapter by using the `ECTO_ADAPTER` environment variable:
$ ECTO_ADAPTER=pg mix test
You may also run `mix test.all` to run the unit tests and all integration tests. You can also use a local Ecto checkout if desired:
$ ECTO_PATH=../ecto mix test.all
### Running containerized tests
It is also possible to run the integration tests under a containerized environment using [earthly](https://earthly.dev/get-earthly). You will also need Docker installed on your system. Then you can run:
$ earthly -P +all
You can also use this to interactively debug any failing integration tests using the corresponding commands:
$ earthly -P -i --build-arg ELIXIR_BASE=1.8.2-erlang-20.3.8.26-alpine-3.11.6 --build-arg MYSQL=5.7 +integration-test-mysql
$ earthly -P -i --build-arg ELIXIR_BASE=1.8.2-erlang-20.3.8.26-alpine-3.11.6 --build-arg MSSQL=2019 +integration-test-mssql
$ earthly -P -i --build-arg ELIXIR_BASE=1.8.2-erlang-20.3.8.26-alpine-3.11.6 --build-arg POSTGRES=11.11 +integration-test-postgres
Then once you enter the containerized shell, you can inspect the underlying databases with the respective commands:
PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -d postgres ecto_test
MYSQL_PASSWORD=root mysql -h 127.0.0.1 -uroot -proot ecto_test
sqlcmd -U sa -P 'some!Password'
## License
Copyright (c) 2012 Plataformatec \
Copyright (c) 2020 Dashbit
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,74 @@
{<<"links">>,[{<<"GitHub">>,<<"https://github.com/elixir-ecto/ecto_sql">>}]}.
{<<"name">>,<<"ecto_sql">>}.
{<<"version">>,<<"3.13.5">>}.
{<<"description">>,<<"SQL-based adapters for Ecto and database migrations">>}.
{<<"elixir">>,<<"~> 1.14">>}.
{<<"files">>,
[<<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>,<<"CHANGELOG.md">>,
<<"lib">>,<<"lib/ecto">>,<<"lib/ecto/adapter">>,
<<"lib/ecto/adapter/migration.ex">>,<<"lib/ecto/adapter/structure.ex">>,
<<"lib/ecto/adapters">>,<<"lib/ecto/adapters/tds.ex">>,
<<"lib/ecto/adapters/myxql">>,<<"lib/ecto/adapters/myxql/connection.ex">>,
<<"lib/ecto/adapters/tds">>,<<"lib/ecto/adapters/tds/types.ex">>,
<<"lib/ecto/adapters/tds/connection.ex">>,<<"lib/ecto/adapters/sql.ex">>,
<<"lib/ecto/adapters/postgres">>,
<<"lib/ecto/adapters/postgres/connection.ex">>,
<<"lib/ecto/adapters/myxql.ex">>,<<"lib/ecto/adapters/postgres.ex">>,
<<"lib/ecto/adapters/sql">>,<<"lib/ecto/adapters/sql/stream.ex">>,
<<"lib/ecto/adapters/sql/sandbox.ex">>,
<<"lib/ecto/adapters/sql/connection.ex">>,
<<"lib/ecto/adapters/sql/application.ex">>,<<"lib/ecto/migration.ex">>,
<<"lib/ecto/migrator.ex">>,<<"lib/ecto/migration">>,
<<"lib/ecto/migration/runner.ex">>,
<<"lib/ecto/migration/schema_migration.ex">>,<<"lib/mix">>,
<<"lib/mix/tasks">>,<<"lib/mix/tasks/ecto.migrations.ex">>,
<<"lib/mix/tasks/ecto.dump.ex">>,<<"lib/mix/tasks/ecto.rollback.ex">>,
<<"lib/mix/tasks/ecto.migrate.ex">>,
<<"lib/mix/tasks/ecto.gen.migration.ex">>,<<"lib/mix/tasks/ecto.load.ex">>,
<<"lib/mix/ecto_sql.ex">>,<<"integration_test/sql">>,
<<"integration_test/sql/migrator.exs">>,
<<"integration_test/sql/query_many.exs">>,
<<"integration_test/sql/lock.exs">>,
<<"integration_test/sql/transaction.exs">>,
<<"integration_test/sql/alter.exs">>,<<"integration_test/sql/stream.exs">>,
<<"integration_test/sql/sql.exs">>,<<"integration_test/sql/sandbox.exs">>,
<<"integration_test/sql/subquery.exs">>,
<<"integration_test/sql/logging.exs">>,
<<"integration_test/sql/migration.exs">>,<<"integration_test/support">>,
<<"integration_test/support/file_helpers.exs">>,
<<"integration_test/support/migration.exs">>,
<<"integration_test/support/repo.exs">>]}.
{<<"app">>,<<"ecto_sql">>}.
{<<"licenses">>,[<<"Apache-2.0">>]}.
{<<"requirements">>,
[[{<<"name">>,<<"ecto">>},
{<<"app">>,<<"ecto">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 3.13.0">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"telemetry">>},
{<<"app">>,<<"telemetry">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 0.4.0 or ~> 1.0">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"db_connection">>},
{<<"app">>,<<"db_connection">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 2.5 or ~> 2.4.1">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"postgrex">>},
{<<"app">>,<<"postgrex">>},
{<<"optional">>,true},
{<<"requirement">>,<<"~> 0.19 or ~> 1.0">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"myxql">>},
{<<"app">>,<<"myxql">>},
{<<"optional">>,true},
{<<"requirement">>,<<"~> 0.7">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"tds">>},
{<<"app">>,<<"tds">>},
{<<"optional">>,true},
{<<"requirement">>,<<"~> 2.1.1 or ~> 2.2">>},
{<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}.

View File

@@ -0,0 +1,90 @@
defmodule Ecto.Integration.AlterTest do
use Ecto.Integration.Case, async: false
alias Ecto.Integration.PoolRepo
defmodule AlterMigrationOne do
use Ecto.Migration
def up do
create table(:alter_col_type) do
add :value, :integer
end
execute "INSERT INTO alter_col_type (value) VALUES (1)"
end
def down do
drop table(:alter_col_type)
end
end
defmodule AlterMigrationTwo do
use Ecto.Migration
def up do
alter table(:alter_col_type) do
modify :value, :numeric
end
end
def down do
alter table(:alter_col_type) do
modify :value, :integer
end
end
end
import Ecto.Query, only: [from: 1, from: 2]
defp run(direction, repo, module) do
Ecto.Migration.Runner.run(repo, repo.config(), 1, module, :forward, direction, direction, log: false)
end
test "reset cache on returning query after alter column type" do
values = from v in "alter_col_type", select: v.value
assert :ok == run(:up, PoolRepo, AlterMigrationOne)
assert PoolRepo.all(values) == [1]
assert :ok == run(:up, PoolRepo, AlterMigrationTwo)
[%Decimal{}] = PoolRepo.all(values)
PoolRepo.transaction(fn() ->
assert [%Decimal{}] = PoolRepo.all(values)
assert :ok == run(:down, PoolRepo, AlterMigrationTwo)
# Optionally fail once with database error when
# already prepared on connection (and clear cache)
try do
PoolRepo.all(values, [mode: :savepoint])
rescue
_ ->
assert PoolRepo.all(values) == [1]
else
result ->
assert result == [1]
end
end)
after
assert :ok == run(:down, PoolRepo, AlterMigrationOne)
end
test "reset cache on parameterized query after alter column type" do
values = from v in "alter_col_type"
assert :ok == run(:up, PoolRepo, AlterMigrationOne)
assert PoolRepo.update_all(values, [set: [value: 2]]) == {1, nil}
assert :ok == run(:up, PoolRepo, AlterMigrationTwo)
assert PoolRepo.update_all(values, [set: [value: 3]]) == {1, nil}
PoolRepo.transaction(fn() ->
assert PoolRepo.update_all(values, [set: [value: Decimal.new(5)]]) == {1, nil}
assert :ok == run(:down, PoolRepo, AlterMigrationTwo)
assert PoolRepo.update_all(values, [set: [value: 6]]) == {1, nil}
end)
after
assert :ok == run(:down, PoolRepo, AlterMigrationOne)
end
end

View File

@@ -0,0 +1,59 @@
defmodule Ecto.Integration.LockTest do
# We can keep this test async as long as it
# is the only one accessing the lock_test table.
use ExUnit.Case, async: true
import Ecto.Query
alias Ecto.Integration.PoolRepo
defmodule LockCounter do
use Ecto.Schema
schema "lock_counters" do
field :count, :integer
end
end
setup do
PoolRepo.delete_all(LockCounter)
:ok
end
test "lock for update" do
%{id: id} = PoolRepo.insert!(%LockCounter{count: 1})
pid = self()
lock_for_update =
Application.get_env(:ecto_sql, :lock_for_update) ||
raise ":lock_for_update not set in :ecto application"
# Here we are manually inserting the lock in the query
# to test multiple adapters. Never do this in actual
# application code: it is not safe and not public.
query = from(lc in LockCounter, where: lc.id == ^id)
query = %{query | lock: lock_for_update}
{:ok, new_pid} =
Task.start_link fn ->
assert_receive :select_for_update, 5000
PoolRepo.transaction(fn ->
[post] = PoolRepo.all(query) # this should block until the other trans. commit
post |> Ecto.Changeset.change(count: post.count + 1) |> PoolRepo.update!
end)
send pid, :updated
end
PoolRepo.transaction(fn ->
[post] = PoolRepo.all(query) # select and lock the row
send new_pid, :select_for_update # signal second process to begin a transaction
post |> Ecto.Changeset.change(count: post.count + 1) |> PoolRepo.update!
end)
assert_receive :updated, 5000
# Final count will be 3 if SELECT ... FOR UPDATE worked and 2 otherwise
assert [%LockCounter{count: 3}] = PoolRepo.all(LockCounter)
end
end

View File

@@ -0,0 +1,857 @@
defmodule Ecto.Integration.LoggingTest do
use Ecto.Integration.Case, async: true
alias Ecto.Integration.TestRepo
alias Ecto.Integration.PoolRepo
alias Ecto.Integration.{Post, Logging, ArrayLogging}
import ExUnit.CaptureLog
import Ecto.Query, only: [from: 2]
describe "telemetry" do
test "dispatches event" do
log = fn event_name, measurements, metadata ->
assert Enum.at(event_name, -1) == :query
assert %{result: {:ok, _res}} = metadata
assert measurements.total_time ==
measurements.query_time + measurements.decode_time + measurements.queue_time
assert measurements.idle_time
send(self(), :logged)
end
Process.put(:telemetry, log)
_ = PoolRepo.all(Post)
assert_received :logged
end
test "dispatches event with stacktrace" do
log = fn _event_name, _measurements, metadata ->
assert %{stacktrace: [_ | _]} = metadata
send(self(), :logged)
end
Process.put(:telemetry, log)
_ = PoolRepo.all(Post, stacktrace: true)
assert_received :logged
end
test "dispatches event with custom options" do
log = fn event_name, _measurements, metadata ->
assert Enum.at(event_name, -1) == :query
assert metadata.options == [:custom_metadata]
send(self(), :logged)
end
Process.put(:telemetry, log)
_ = PoolRepo.all(Post, telemetry_options: [:custom_metadata])
assert_received :logged
end
test "dispatches under another event name" do
log = fn [:custom], measurements, metadata ->
assert %{result: {:ok, _res}} = metadata
assert measurements.total_time ==
measurements.query_time + measurements.decode_time + measurements.queue_time
assert measurements.idle_time
send(self(), :logged)
end
Process.put(:telemetry, log)
_ = PoolRepo.all(Post, telemetry_event: [:custom])
assert_received :logged
end
test "is not dispatched with no event name" do
Process.put(:telemetry, fn _, _ -> raise "never called" end)
_ = TestRepo.all(Post, telemetry_event: nil)
refute_received :logged
end
test "cast params" do
uuid_module =
if TestRepo.__adapter__() == Ecto.Adapters.Tds do
Tds.Ecto.UUID
else
Ecto.UUID
end
uuid = uuid_module.generate()
dumped_uuid = uuid_module.dump!(uuid)
log = fn _event_name, _measurements, metadata ->
assert [dumped_uuid] == metadata.params
assert [uuid] == metadata.cast_params
send(self(), :logged)
end
Process.put(:telemetry, log)
TestRepo.all(from l in Logging, where: l.uuid == ^uuid)
assert_received :logged
end
end
describe "logs" do
@stacktrace_opts [stacktrace: true, log: :error]
defp stacktrace_entry(line) do
~r/↳ anonymous fn\/0 in Ecto.Integration.LoggingTest.\"test logs includes stacktraces\"\/1, at: .*integration_test\/sql\/logging.exs:#{line - 3}/
end
test "when some measurements are nil" do
assert capture_log(fn -> TestRepo.query("BEG", [], log: :error) end) =~
"[error]"
end
test "includes stacktraces" do
assert capture_log(fn ->
TestRepo.all(Post, @stacktrace_opts)
:ok
end) =~ stacktrace_entry(__ENV__.line)
assert capture_log(fn ->
TestRepo.insert(%Post{}, @stacktrace_opts)
:ok
end) =~ stacktrace_entry(__ENV__.line)
assert capture_log(fn ->
# Test cascading options
Ecto.Multi.new()
|> Ecto.Multi.insert(:post, %Post{})
|> TestRepo.transaction(@stacktrace_opts)
:ok
end) =~ stacktrace_entry(__ENV__.line)
assert capture_log(fn ->
# In theory we should point to the call _inside_ run
# but all multi calls point to the transaction starting point.
Ecto.Multi.new()
|> Ecto.Multi.run(:all, fn _, _ -> {:ok, TestRepo.all(Post, @stacktrace_opts)} end)
|> TestRepo.transaction()
:ok
end) =~ stacktrace_entry(__ENV__.line)
out = capture_log(fn ->
TestRepo.all(Post, Keyword.put(@stacktrace_opts, :log_stacktrace_mfa, {Ecto.Adapters.SQL, :first_non_ecto_stacktrace, [2]}))
:ok
end)
assert out =~ stacktrace_entry(__ENV__.line - 2)
# We are a bit liberal with what we expect as we don't want to tie to internal ExUnit code
assert out =~ ~r/ ExUnit.CaptureLog.*/
end
test "with custom log level" do
assert capture_log(fn -> TestRepo.insert!(%Post{title: "1"}, log: :error) end) =~
"[error]"
# We cannot assert on the result because it depends on the suite log level
capture_log(fn ->
TestRepo.insert!(%Post{title: "1"}, log: true)
end)
# But this assertion is always true
assert capture_log(fn ->
TestRepo.insert!(%Post{title: "1"}, log: false)
end) == ""
end
test "with a log: true override when logging is disabled" do
refute capture_log(fn ->
TestRepo.insert!(%Post{title: "1"}, log: true)
end) =~ "an exception was raised logging"
end
test "with unspecified :log option when logging is disabled" do
refute capture_log(fn ->
TestRepo.insert!(%Post{title: "1"})
end) =~ "an exception was raised logging"
end
end
describe "parameter logging" do
@describetag :parameter_logging
@uuid_regex ~r/[0-9a-f]{2}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
@naive_datetime_regex ~r/~N\[[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\]/
test "for insert_all with query" do
# Source query
int = 1
uuid = Ecto.UUID.generate()
source_query =
from l in Logging,
where: l.int == ^int and l.uuid == ^uuid,
select: %{uuid: l.uuid, int: l.int}
# Ensure parameters are complete and in correct order
log =
capture_log(fn ->
TestRepo.insert_all(Logging, source_query, log: :info)
end)
param_regex = ~r/\[(?<int>.+), \"(?<uuid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
# Query parameters
assert param_logs["int"] == Integer.to_string(int)
assert param_logs["uuid"] == uuid
end
@tag :insert_select
test "for insert_all with entries" do
# Row 1
int = 1
uuid = Ecto.UUID.generate()
uuid_query = from l in Logging, where: l.int == ^int and l.uuid == ^uuid, select: l.uuid
datetime = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
row1 = [
int: int,
uuid: uuid_query,
inserted_at: datetime,
updated_at: datetime
]
# Row 2
int2 = 2
uuid2 = Ecto.UUID.generate()
int_query = from l in Logging, where: l.int == ^int2 and l.uuid == ^uuid2, select: l.int
datetime2 = NaiveDateTime.add(datetime, 1)
row2 = [
int: int_query,
uuid: uuid2,
inserted_at: datetime2,
updated_at: datetime2
]
# Extract the parameters from the log:
# 1. Remove the colour codes
# 2. Remove the log level
# 3. Capture everything inside of the square brackets
log =
capture_log(fn ->
TestRepo.insert_all(Logging, [row1, row2], log: :info)
end)
log = Regex.replace(~r/\e\[[0-9]+m/, log, "")
log = Regex.replace(~r/\[info\]/, log, "")
log = Regex.named_captures(~r/\[(?<params>.+)\]/, log)
log_params = String.split(log["params"], ",")
# Compute the expected parameters in the right order.
# This involves recreating the headers in the same order
# as `insert_all`. The user values come first and then
# the autogenerated id
headers =
row1
|> Enum.reduce(%{}, fn {field, _}, headers -> Map.put(headers, field, true) end)
|> Map.put(:bid, true)
|> Map.keys()
row1_regex = [
int: "#{int}",
uuid: ["#{int}", uuid],
inserted_at: inspect(datetime),
updated_at: inspect(datetime),
bid: @uuid_regex
]
row2_regex = [
int: ["#{int2}", uuid2],
uuid: uuid2,
inserted_at: inspect(datetime2),
updated_at: inspect(datetime2),
bid: @uuid_regex
]
expected_param_regex =
Enum.flat_map([row1_regex, row2_regex], fn row ->
Enum.flat_map(headers, fn field ->
case Keyword.get(row, field) do
params when is_list(params) -> params
param -> [param]
end
end)
end)
assert length(log_params) == length(expected_param_regex)
Enum.zip(log_params, expected_param_regex)
|> Enum.each(fn {log, expected_regex} ->
assert log =~ expected_regex
end)
end
@tag :insert_select
@tag :placeholders
test "for insert_all with entries and placeholders" do
# Placeholders
datetime = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
datetime2 = NaiveDateTime.add(datetime, 1)
placeholder_map = %{datetime: datetime, datetime2: datetime2}
# Row 1
int = 1
uuid = Ecto.UUID.generate()
uuid_query = from l in Logging, where: l.int == ^int and l.uuid == ^uuid, select: l.uuid
row1 = [
int: int,
uuid: uuid_query,
inserted_at: {:placeholder, :datetime},
updated_at: {:placeholder, :datetime}
]
# Row 2
int2 = 2
uuid2 = Ecto.UUID.generate()
int_query = from l in Logging, where: l.int == ^int2 and l.uuid == ^uuid2, select: l.int
row2 = [
int: int_query,
uuid: uuid2,
inserted_at: {:placeholder, :datetime2},
updated_at: {:placeholder, :datetime2}
]
# Extract the parameters from the log:
# 1. Remove the colour codes
# 2. Remove the log level
# 3. Capture everything inside of the square brackets
log =
capture_log(fn ->
TestRepo.insert_all(Logging, [row1, row2],
placeholders: placeholder_map,
log: :info
)
end)
log = Regex.replace(~r/\e\[[0-9]+m/, log, "")
log = Regex.replace(~r/\[info\]/, log, "")
log = Regex.named_captures(~r/\[(?<params>.+)\]/, log)
log_params = String.split(log["params"], ",")
# Compute the expected parameters in the right order.
# This involves recreating the headers in the same order
# as `insert_all`. The placeholders come first and then
# the user values and then the autogenerated id
headers =
row1
|> Enum.reduce(%{}, fn {field, _}, headers -> Map.put(headers, field, true) end)
|> Map.put(:bid, true)
|> Map.drop([:inserted_at, :updated_at])
|> Map.keys()
row1_regex = [
int: "#{int}",
uuid: ["#{int}", uuid],
bid: @uuid_regex
]
row2_regex = [
int: ["#{int2}", uuid2],
uuid: uuid2,
bid: @uuid_regex
]
row_param_regex =
Enum.flat_map([row1_regex, row2_regex], fn row ->
Enum.flat_map(headers, fn field ->
case Keyword.get(row, field) do
params when is_list(params) -> params
param -> [param]
end
end)
end)
placeholder_regex = [inspect(datetime), inspect(datetime2)]
expected_param_regex = placeholder_regex ++ row_param_regex
assert length(log_params) == length(expected_param_regex)
Enum.zip(log_params, expected_param_regex)
|> Enum.each(fn {log, expected_regex} ->
assert log =~ expected_regex
end)
end
@tag :with_conflict_target
test "for insert_all with query with conflict query" do
# Source query
int = 1
uuid = Ecto.UUID.generate()
source_query =
from l in Logging,
where: l.int == ^int and l.uuid == ^uuid,
select: %{uuid: l.uuid, int: l.int}
# Conflict query
conflict_int = 0
conflict_uuid = Ecto.UUID.generate()
conflict_update = 2
conflict_query =
from l in Logging,
where: l.int == ^conflict_int and l.uuid == ^conflict_uuid,
update: [set: [int: ^conflict_update]]
# Ensure parameters are complete and in correct order
log =
capture_log(fn ->
TestRepo.insert_all(Logging, source_query,
on_conflict: conflict_query,
conflict_target: :bid,
log: :info
)
end)
param_regex =
~r/\[(?<int>.+), \"(?<uuid>.+)\", (?<conflict_update>.+), (?<conflict_int>.+), \"(?<conflict_uuid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
# Query parameters
assert param_logs["int"] == Integer.to_string(int)
assert param_logs["uuid"] == uuid
# Conflict query parameters
assert param_logs["conflict_update"] == Integer.to_string(conflict_update)
assert param_logs["conflict_int"] == Integer.to_string(conflict_int)
assert param_logs["conflict_uuid"] == conflict_uuid
end
@tag :insert_select
@tag :with_conflict_target
test "for insert_all with entries conflict query" do
# Row 1
int = 1
uuid = Ecto.UUID.generate()
uuid_query = from l in Logging, where: l.int == ^int and l.uuid == ^uuid, select: l.uuid
datetime = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
row1 = [
int: int,
uuid: uuid_query,
inserted_at: datetime,
updated_at: datetime
]
# Row 2
int2 = 2
uuid2 = Ecto.UUID.generate()
int_query = from l in Logging, where: l.int == ^int2 and l.uuid == ^uuid2, select: l.int
datetime2 = NaiveDateTime.add(datetime, 1)
row2 = [
int: int_query,
uuid: uuid2,
inserted_at: datetime2,
updated_at: datetime2
]
# Conflict query
conflict_int = 0
conflict_uuid = Ecto.UUID.generate()
conflict_update = 2
conflict_query =
from l in Logging,
where: l.int == ^conflict_int and l.uuid == ^conflict_uuid,
update: [set: [int: ^conflict_update]]
# Extract the parameters from the log:
# 1. Remove the colour codes
# 2. Remove the log level
# 3. Capture everything inside of the square brackets
log =
capture_log(fn ->
TestRepo.insert_all(Logging, [row1, row2],
on_conflict: conflict_query,
conflict_target: :bid,
log: :info
)
end)
log = Regex.replace(~r/\e\[[0-9]+m/, log, "")
log = Regex.replace(~r/\[info\]/, log, "")
log = Regex.named_captures(~r/\[(?<params>.+)\]/, log)
log_params = String.split(log["params"], ",")
# Compute the expected parameters in the right order.
# This involves recreating the headers in the same order
# as `insert_all`. The user values come first, then
# the autogenerated id and then the conflict params
headers =
row1
|> Enum.reduce(%{}, fn {field, _}, headers -> Map.put(headers, field, true) end)
|> Map.put(:bid, true)
|> Map.keys()
row1_regex = [
int: "#{int}",
uuid: ["#{int}", uuid],
bid: @uuid_regex,
inserted_at: inspect(datetime),
updated_at: inspect(datetime)
]
row2_regex = [
int: ["#{int2}", uuid2],
uuid: uuid2,
bid: @uuid_regex,
inserted_at: inspect(datetime2),
updated_at: inspect(datetime2)
]
row_param_regex =
Enum.flat_map([row1_regex, row2_regex], fn row ->
Enum.flat_map(headers, fn field ->
case Keyword.get(row, field) do
row_params when is_list(row_params) -> row_params
row_param -> [row_param]
end
end)
end)
conflict_param_regex = ["#{conflict_update}", "#{conflict_int}", conflict_uuid]
expected_param_regex = row_param_regex ++ conflict_param_regex
assert length(log_params) == length(expected_param_regex)
Enum.zip(log_params, expected_param_regex)
|> Enum.each(fn {log, expected_regex} ->
assert log =~ expected_regex
end)
end
@tag :insert_select
@tag :placeholders
@tag :with_conflict_target
test "for insert_all with entries, placeholders and conflict query" do
# Row 1
int = 1
uuid = Ecto.UUID.generate()
uuid_query = from l in Logging, where: l.int == ^int and l.uuid == ^uuid, select: l.uuid
row1 = [
int: int,
uuid: uuid_query,
inserted_at: {:placeholder, :datetime},
updated_at: {:placeholder, :datetime2}
]
# Row 2
int2 = 2
uuid2 = Ecto.UUID.generate()
int_query = from l in Logging, where: l.int == ^int2 and l.uuid == ^uuid2, select: l.int
row2 = [
int: int_query,
uuid: uuid2,
inserted_at: {:placeholder, :datetime},
updated_at: {:placeholder, :datetime2}
]
# Placeholders
datetime = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
datetime2 = NaiveDateTime.add(datetime, 1)
placeholder_map = %{datetime: datetime, datetime2: datetime2}
# Conflict query
conflict_int = 0
conflict_uuid = Ecto.UUID.generate()
conflict_update = 2
conflict_query =
from l in Logging,
where: l.int == ^conflict_int and l.uuid == ^conflict_uuid,
update: [set: [int: ^conflict_update]]
# Extract the parameters from the log:
# 1. Remove the colour codes
# 2. Remove the log level
# 3. Capture everything inside of the square brackets
log =
capture_log(fn ->
TestRepo.insert_all(Logging, [row1, row2],
placeholders: placeholder_map,
on_conflict: conflict_query,
conflict_target: :bid,
log: :info
)
end)
log = Regex.replace(~r/\e\[[0-9]+m/, log, "")
log = Regex.replace(~r/\[info\]/, log, "")
log = Regex.named_captures(~r/\[(?<params>.+)\]/, log)
log_params = String.split(log["params"], ",")
# Compute the expected parameters in the right order.
# This involves recreating the headers in the same order
# as `insert_all`. The placeholders come first, then the
# user value, then the autogenerated id and then the conflict
# params
headers =
row1
|> Enum.reduce(%{}, fn {field, _}, headers -> Map.put(headers, field, true) end)
|> Map.put(:bid, true)
|> Map.drop([:inserted_at, :updated_at])
|> Map.keys()
row1_regex = [
int: "#{int}",
uuid: ["#{int}", uuid],
bid: @uuid_regex
]
row2_regex = [
int: ["#{int2}", uuid2],
uuid: uuid2,
bid: @uuid_regex
]
row_param_regex =
Enum.flat_map([row1_regex, row2_regex], fn row ->
Enum.flat_map(headers, fn field ->
case Keyword.get(row, field) do
row_params when is_list(row_params) -> row_params
row_param -> [row_param]
end
end)
end)
placeholder_regex = [inspect(datetime), inspect(datetime2)]
conflict_param_regex = ["#{conflict_update}", "#{conflict_int}", conflict_uuid]
expected_param_regex = placeholder_regex ++ row_param_regex ++ conflict_param_regex
assert length(log_params) == length(expected_param_regex)
Enum.zip(log_params, expected_param_regex)
|> Enum.each(fn {log, expected_regex} ->
assert log =~ expected_regex
end)
end
test "for insert" do
# Insert values
int = 1
uuid = Ecto.UUID.generate()
# Ensure parameters are complete and in correct order
log =
capture_log(fn ->
TestRepo.insert!(%Logging{uuid: uuid, int: int},
log: :info
)
end)
param_regex =
~r/\[(?<int>.+), \"(?<uuid>.+)\", (?<inserted_at>.+), (?<updated_at>.+), \"(?<bid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
# User changes
assert param_logs["int"] == Integer.to_string(int)
assert param_logs["uuid"] == uuid
# Autogenerated changes
assert param_logs["inserted_at"] =~ @naive_datetime_regex
assert param_logs["updated_at"] =~ @naive_datetime_regex
# Filters
assert param_logs["bid"] =~ @uuid_regex
end
@tag :with_conflict_target
test "for insert with conflict query" do
# Insert values
int = 1
uuid = Ecto.UUID.generate()
# Conflict query
conflict_int = 0
conflict_uuid = Ecto.UUID.generate()
conflict_update = 2
conflict_query =
from l in Logging,
where: l.int == ^conflict_int and l.uuid == ^conflict_uuid,
update: [set: [int: ^conflict_update]]
# Ensure parameters are complete and in correct order
log =
capture_log(fn ->
TestRepo.insert!(%Logging{uuid: uuid, int: int},
on_conflict: conflict_query,
conflict_target: :bid,
log: :info
)
end)
param_regex =
~r/\[(?<int>.+), \"(?<uuid>.+)\", (?<inserted_at>.+), (?<updated_at>.+), \"(?<bid>.+)\", (?<conflict_update>.+), (?<conflict_int>.+), \"(?<conflict_uuid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
# User changes
assert param_logs["int"] == Integer.to_string(int)
assert param_logs["uuid"] == uuid
# Autogenerated changes
assert param_logs["inserted_at"] =~ @naive_datetime_regex
assert param_logs["updated_at"] =~ @naive_datetime_regex
# Filters
assert param_logs["bid"] =~ @uuid_regex
# Conflict query parameters
assert param_logs["conflict_update"] == Integer.to_string(conflict_update)
assert param_logs["conflict_int"] == Integer.to_string(conflict_int)
assert param_logs["conflict_uuid"] == conflict_uuid
end
test "for update" do
# Update values
int = 1
uuid = Ecto.UUID.generate()
current = TestRepo.insert!(%Logging{})
# Ensure parameters are complete and in correct order
log =
capture_log(fn ->
TestRepo.update!(Ecto.Changeset.change(current, %{uuid: uuid, int: int}), log: :info)
end)
param_regex = ~r/\[(?<int>.+), \"(?<uuid>.+)\", (?<updated_at>.+), \"(?<bid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
# User changes
assert param_logs["int"] == Integer.to_string(int)
assert param_logs["uuid"] == uuid
# Autogenerated changes
assert param_logs["updated_at"] =~ @naive_datetime_regex
# Filters
assert param_logs["bid"] == current.bid
end
test "for delete" do
current = TestRepo.insert!(%Logging{})
# Ensure parameters are complete and in correct order
log =
capture_log(fn ->
TestRepo.delete!(current, log: :info)
end)
param_regex = ~r/\[\"(?<bid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
# Filters
assert param_logs["bid"] == current.bid
end
test "for queries" do
int = 1
uuid = Ecto.UUID.generate()
# all
log =
capture_log(fn ->
TestRepo.all(
from(l in Logging,
select: type(^"1", :integer),
where: l.int == ^int and l.uuid == ^uuid
),
log: :info
)
end)
param_regex = ~r/\[(?<tagged_int>.+), (?<int>.+), \"(?<uuid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
assert param_logs["tagged_int"] == Integer.to_string(int)
assert param_logs["int"] == Integer.to_string(int)
assert param_logs["uuid"] == uuid
# update_all
update = 2
log =
capture_log(fn ->
from(l in Logging,
where: l.int == ^int and l.uuid == ^uuid,
update: [set: [int: ^update]]
)
|> TestRepo.update_all([], log: :info)
end)
param_regex = ~r/\[(?<update>.+), (?<int>.+), \"(?<uuid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
assert param_logs["update"] == Integer.to_string(update)
assert param_logs["int"] == Integer.to_string(int)
assert param_logs["uuid"] == uuid
# delete_all
log =
capture_log(fn ->
TestRepo.delete_all(from(l in Logging, where: l.int == ^int and l.uuid == ^uuid),
log: :info
)
end)
param_regex = ~r/\[(?<int>.+), \"(?<uuid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
assert param_logs["int"] == Integer.to_string(int)
assert param_logs["uuid"] == uuid
end
@tag :stream
test "for queries with stream" do
int = 1
uuid = Ecto.UUID.generate()
log =
capture_log(fn ->
stream =
TestRepo.stream(from(l in Logging, where: l.int == ^int and l.uuid == ^uuid),
log: :info
)
TestRepo.transaction(fn -> Enum.to_list(stream) end)
end)
param_regex = ~r/\[(?<int>.+), \"(?<uuid>.+)\"\]/
param_logs = Regex.named_captures(param_regex, log)
assert param_logs["int"] == Integer.to_string(int)
assert param_logs["uuid"] == uuid
end
@tag :array_type
test "for queries with array type" do
uuid = Ecto.UUID.generate()
uuid2 = Ecto.UUID.generate()
log =
capture_log(fn ->
TestRepo.all(from(a in ArrayLogging, where: a.uuids == ^[uuid, uuid2]),
log: :info
)
end)
param_regex = ~r/\[(?<uuids>\[.+\])\]/
param_logs = Regex.named_captures(param_regex, log)
assert param_logs["uuids"] == "[\"#{uuid}\", \"#{uuid2}\"]"
end
end
end

View File

@@ -0,0 +1,775 @@
defmodule Ecto.Integration.MigrationTest do
use ExUnit.Case, async: true
alias Ecto.Integration.{TestRepo, PoolRepo}
defmodule CreateMigration do
use Ecto.Migration
@table table(:create_table_migration)
@index index(:create_table_migration, [:value], unique: true)
def up do
create @table do
add :value, :integer
end
create @index
end
def down do
drop @index
drop @table
end
end
defmodule AddColumnMigration do
use Ecto.Migration
def up do
create table(:add_col_migration) do
add :value, :integer
end
alter table(:add_col_migration) do
add :to_be_added, :integer
end
execute "INSERT INTO add_col_migration (value, to_be_added) VALUES (1, 2)"
end
def down do
drop table(:add_col_migration)
end
end
defmodule AlterColumnMigration do
use Ecto.Migration
def up do
create table(:alter_col_migration) do
add :from_null_to_not_null, :integer
add :from_not_null_to_null, :integer, null: false
add :from_default_to_no_default, :integer, default: 0
add :from_no_default_to_default, :integer
end
alter table(:alter_col_migration) do
modify :from_null_to_not_null, :string, null: false
modify :from_not_null_to_null, :string, null: true
modify :from_default_to_no_default, :integer, default: nil
modify :from_no_default_to_default, :integer, default: 0
end
execute "INSERT INTO alter_col_migration (from_null_to_not_null) VALUES ('foo')"
end
def down do
drop table(:alter_col_migration)
end
end
defmodule AlterColumnFromMigration do
use Ecto.Migration
def change do
create table(:modify_from_products) do
add :value, :integer
add :nullable, :integer, null: false
end
if direction() == :up do
flush()
PoolRepo.insert_all "modify_from_products", [[value: 1, nullable: 1]]
end
alter table(:modify_from_products) do
modify :value, :bigint, from: :integer
modify :nullable, :bigint, null: true, from: {:integer, null: false}
end
end
end
defmodule AlterColumnFromPkeyMigration do
use Ecto.Migration
def change do
create table(:modify_from_authors, primary_key: false) do
add :id, :integer, primary_key: true
end
create table(:modify_from_posts) do
add :author_id, references(:modify_from_authors, type: :integer)
end
if direction() == :up do
flush()
PoolRepo.insert_all "modify_from_authors", [[id: 1]]
PoolRepo.insert_all "modify_from_posts", [[author_id: 1]]
end
alter table(:modify_from_posts) do
# remove the constraints modify_from_posts_author_id_fkey
modify :author_id, :integer, from: references(:modify_from_authors, type: :integer)
end
alter table(:modify_from_authors) do
modify :id, :bigint, from: :integer
end
alter table(:modify_from_posts) do
# add the constraints modify_from_posts_author_id_fkey
modify :author_id, references(:modify_from_authors, type: :bigint), from: :integer
end
end
end
defmodule AlterForeignKeyOnDeleteMigration do
use Ecto.Migration
def up do
create table(:alter_fk_users)
create table(:alter_fk_posts) do
add :alter_fk_user_id, :id
end
alter table(:alter_fk_posts) do
modify :alter_fk_user_id, references(:alter_fk_users, on_delete: :nilify_all)
end
end
def down do
drop table(:alter_fk_posts)
drop table(:alter_fk_users)
end
end
defmodule AlterForeignKeyOnUpdateMigration do
use Ecto.Migration
def up do
create table(:alter_fk_users)
create table(:alter_fk_posts) do
add :alter_fk_user_id, :id
end
alter table(:alter_fk_posts) do
modify :alter_fk_user_id, references(:alter_fk_users, on_update: :update_all)
end
end
def down do
drop table(:alter_fk_posts)
drop table(:alter_fk_users)
end
end
defmodule DropColumnMigration do
use Ecto.Migration
def up do
create table(:drop_col_migration) do
add :value, :integer
add :to_be_removed, :integer
end
execute "INSERT INTO drop_col_migration (value, to_be_removed) VALUES (1, 2)"
alter table(:drop_col_migration) do
remove :to_be_removed
end
end
def down do
drop table(:drop_col_migration)
end
end
defmodule RenameColumnMigration do
use Ecto.Migration
def up do
create table(:rename_col_migration) do
add :to_be_renamed, :integer
end
rename table(:rename_col_migration), :to_be_renamed, to: :was_renamed
execute "INSERT INTO rename_col_migration (was_renamed) VALUES (1)"
end
def down do
drop table(:rename_col_migration)
end
end
defmodule OnDeleteMigration do
use Ecto.Migration
def up do
create table(:parent1)
create table(:parent2)
create table(:ref_migration) do
add :parent1, references(:parent1, on_delete: :nilify_all)
end
alter table(:ref_migration) do
add :parent2, references(:parent2, on_delete: :delete_all)
end
end
def down do
drop table(:ref_migration)
drop table(:parent1)
drop table(:parent2)
end
end
defmodule OnDeleteNilifyColumnsMigration do
use Ecto.Migration
def up do
create table(:parent) do
add :col1, :integer
add :col2, :integer
end
create unique_index(:parent, [:id, :col1, :col2])
create table(:ref) do
add :col1, :integer
add :col2, :integer
add :parent_id,
references(:parent,
with: [col1: :col1, col2: :col2],
on_delete: {:nilify, [:parent_id, :col2]}
)
end
end
def down do
drop table(:ref)
drop table(:parent)
end
end
defmodule OnDeleteDefaultAllMigration do
use Ecto.Migration
def up do
create table(:parent, primary_key: [type: :bigint]) do
add :col1, :integer
add :col2, :integer
end
create unique_index(:parent, [:id, :col1, :col2])
create table(:ref) do
add :col1, :integer, default: 2
add :col2, :integer, default: 3
add :parent_id,
references(:parent,
with: [col1: :col1, col2: :col2],
on_delete: :default_all
), default: 1
end
end
def down do
drop table(:ref)
drop table(:parent)
end
end
defmodule OnDeleteDefaultColumnsMigration do
use Ecto.Migration
def up do
create table(:parent, primary_key: [type: :bigint]) do
add :col1, :integer
add :col2, :integer
end
create unique_index(:parent, [:id, :col1, :col2])
create table(:ref) do
add :col1, :integer, default: 2
add :col2, :integer, default: 3
add :parent_id,
references(:parent,
with: [col1: :col1, col2: :col2],
on_delete: {:default, [:parent_id, :col2]}
), default: 1
end
end
def down do
drop table(:ref)
drop table(:parent)
end
end
defmodule CompositeForeignKeyMigration do
use Ecto.Migration
def change do
create table(:composite_parent) do
add :key_id, :integer
end
create unique_index(:composite_parent, [:id, :key_id])
create table(:composite_child) do
add :parent_key_id, :integer
add :parent_id, references(:composite_parent, with: [parent_key_id: :key_id])
end
end
end
defmodule RenameIndexMigration do
use Ecto.Migration
def change do
create table(:composite_parent) do
add :key_id, :integer
end
create unique_index(:composite_parent, [:id, :key_id], name: "old_index_name")
rename index(:composite_parent, [:id, :key_id], name: "old_index_name"), to: "new_index_name"
end
end
defmodule ReferencesRollbackMigration do
use Ecto.Migration
def change do
create table(:parent) do
add :name, :string
end
create table(:child) do
add :parent_id, references(:parent)
end
end
end
defmodule RenameMigration do
use Ecto.Migration
@table_current table(:posts_migration)
@table_new table(:new_posts_migration)
def up do
create @table_current
rename @table_current, to: @table_new
end
def down do
drop @table_new
end
end
defmodule PrefixMigration do
use Ecto.Migration
@prefix "ecto_prefix_test"
def up do
execute TestRepo.create_prefix(@prefix)
create table(:first, prefix: @prefix)
create table(:second, prefix: @prefix) do
add :first_id, references(:first)
end
end
def down do
drop table(:second, prefix: @prefix)
drop table(:first, prefix: @prefix)
execute TestRepo.drop_prefix(@prefix)
end
end
defmodule NoSQLMigration do
use Ecto.Migration
def up do
create table(:collection, options: [capped: true])
execute create: "collection"
end
end
defmodule Parent do
use Ecto.Schema
schema "parent" do
end
end
defmodule NoErrorTableMigration do
use Ecto.Migration
def change do
create_if_not_exists table(:existing) do
add :name, :string
end
create_if_not_exists table(:existing) do
add :name, :string
end
create_if_not_exists table(:existing)
drop_if_exists table(:existing)
drop_if_exists table(:existing)
end
end
defmodule NoErrorIndexMigration do
use Ecto.Migration
def change do
create_if_not_exists index(:posts, [:title])
create_if_not_exists index(:posts, [:title])
drop_if_exists index(:posts, [:title])
drop_if_exists index(:posts, [:title])
end
end
defmodule InferredDropIndexMigration do
use Ecto.Migration
def change do
create index(:posts, [:title])
end
end
defmodule AlterPrimaryKeyMigration do
use Ecto.Migration
def change do
create table(:no_pk, primary_key: false) do
add :dummy, :string
end
alter table(:no_pk) do
add :id, :serial, primary_key: true
end
end
end
defmodule AddColumnIfNotExistsMigration do
use Ecto.Migration
def up do
create table(:add_col_if_not_exists_migration)
alter table(:add_col_if_not_exists_migration) do
add_if_not_exists :value, :integer
add_if_not_exists :to_be_added, :integer
end
execute "INSERT INTO add_col_if_not_exists_migration (value, to_be_added) VALUES (1, 2)"
end
def down do
drop table(:add_col_if_not_exists_migration)
end
end
defmodule DropColumnIfExistsMigration do
use Ecto.Migration
def up do
create table(:drop_col_if_exists_migration) do
add :value, :integer
add :to_be_removed, :integer
end
execute "INSERT INTO drop_col_if_exists_migration (value, to_be_removed) VALUES (1, 2)"
alter table(:drop_col_if_exists_migration) do
remove_if_exists :to_be_removed, :integer
end
end
def down do
drop table(:drop_col_if_exists_migration)
end
end
defmodule NoErrorOnConditionalColumnMigration do
use Ecto.Migration
def up do
create table(:no_error_on_conditional_column_migration)
alter table(:no_error_on_conditional_column_migration) do
add_if_not_exists :value, :integer
add_if_not_exists :value, :integer
remove_if_exists :value, :integer
remove_if_exists :value, :integer
end
end
def down do
drop table(:no_error_on_conditional_column_migration)
end
end
import Ecto.Query, only: [from: 2]
import Ecto.Migrator, only: [up: 4, down: 4]
# Avoid migration out of order warnings
@moduletag :capture_log
@base_migration 1_000_000
setup do
{:ok, migration_number: System.unique_integer([:positive]) + @base_migration}
end
test "create and drop table and indexes", %{migration_number: num} do
assert :ok == up(PoolRepo, num, CreateMigration, log: false)
assert :ok == down(PoolRepo, num, CreateMigration, log: false)
end
test "correctly infers how to drop index", %{migration_number: num} do
assert :ok == up(PoolRepo, num, InferredDropIndexMigration, log: false)
assert :ok == down(PoolRepo, num, InferredDropIndexMigration, log: false)
end
test "supports on delete", %{migration_number: num} do
assert :ok == up(PoolRepo, num, OnDeleteMigration, log: false)
parent1 = PoolRepo.insert! Ecto.put_meta(%Parent{}, source: "parent1")
parent2 = PoolRepo.insert! Ecto.put_meta(%Parent{}, source: "parent2")
writer = "INSERT INTO ref_migration (parent1, parent2) VALUES (#{parent1.id}, #{parent2.id})"
PoolRepo.query!(writer)
reader = from r in "ref_migration", select: {r.parent1, r.parent2}
assert PoolRepo.all(reader) == [{parent1.id, parent2.id}]
PoolRepo.delete!(parent1)
assert PoolRepo.all(reader) == [{nil, parent2.id}]
PoolRepo.delete!(parent2)
assert PoolRepo.all(reader) == []
assert :ok == down(PoolRepo, num, OnDeleteMigration, log: false)
end
test "composite foreign keys", %{migration_number: num} do
assert :ok == up(PoolRepo, num, CompositeForeignKeyMigration, log: false)
PoolRepo.insert_all("composite_parent", [[key_id: 2]])
assert [id] = PoolRepo.all(from p in "composite_parent", select: p.id)
catch_error(PoolRepo.insert_all("composite_child", [[parent_id: id, parent_key_id: 1]]))
assert {1, nil} = PoolRepo.insert_all("composite_child", [[parent_id: id, parent_key_id: 2]])
assert :ok == down(PoolRepo, num, CompositeForeignKeyMigration, log: false)
end
test "rename index", %{migration_number: num} do
assert :ok == up(PoolRepo, num, RenameIndexMigration, log: false)
assert :ok == down(PoolRepo, num, RenameIndexMigration, log: false)
end
test "rolls back references in change/1", %{migration_number: num} do
assert :ok == up(PoolRepo, num, ReferencesRollbackMigration, log: false)
assert :ok == down(PoolRepo, num, ReferencesRollbackMigration, log: false)
end
test "create table if not exists and drop table if exists does not raise on failure", %{migration_number: num} do
assert :ok == up(PoolRepo, num, NoErrorTableMigration, log: false)
end
@tag :create_index_if_not_exists
test "create index if not exists and drop index if exists does not raise on failure", %{migration_number: num} do
assert :ok == up(PoolRepo, num, NoErrorIndexMigration, log: false)
end
test "raises on NoSQL migrations", %{migration_number: num} do
assert_raise ArgumentError, ~r"does not support keyword lists in :options", fn ->
up(PoolRepo, num, NoSQLMigration, log: false)
end
end
@tag :add_column
test "add column", %{migration_number: num} do
assert :ok == up(PoolRepo, num, AddColumnMigration, log: false)
assert [2] == PoolRepo.all from p in "add_col_migration", select: p.to_be_added
:ok = down(PoolRepo, num, AddColumnMigration, log: false)
end
@tag :modify_column
test "modify column", %{migration_number: num} do
assert :ok == up(PoolRepo, num, AlterColumnMigration, log: false)
assert ["foo"] ==
PoolRepo.all from p in "alter_col_migration", select: p.from_null_to_not_null
assert [nil] ==
PoolRepo.all from p in "alter_col_migration", select: p.from_not_null_to_null
assert [nil] ==
PoolRepo.all from p in "alter_col_migration", select: p.from_default_to_no_default
assert [0] ==
PoolRepo.all from p in "alter_col_migration", select: p.from_no_default_to_default
query = "INSERT INTO `alter_col_migration` (\"from_not_null_to_null\") VALUES ('foo')"
assert catch_error(PoolRepo.query!(query))
:ok = down(PoolRepo, num, AlterColumnMigration, log: false)
end
@tag :modify_column
test "modify column with from", %{migration_number: num} do
assert :ok == up(PoolRepo, num, AlterColumnFromMigration, log: false)
assert [1] ==
PoolRepo.all from p in "modify_from_products", select: p.value
:ok = down(PoolRepo, num, AlterColumnFromMigration, log: false)
end
@tag :alter_primary_key
test "modify column with from and pkey", %{migration_number: num} do
assert :ok == up(PoolRepo, num, AlterColumnFromPkeyMigration, log: false)
assert [1] ==
PoolRepo.all from p in "modify_from_posts", select: p.author_id
:ok = down(PoolRepo, num, AlterColumnFromPkeyMigration, log: false)
end
@tag :alter_foreign_key
test "modify foreign key's on_delete constraint", %{migration_number: num} do
assert :ok == up(PoolRepo, num, AlterForeignKeyOnDeleteMigration, log: false)
PoolRepo.insert_all("alter_fk_users", [[]])
assert [id] = PoolRepo.all from p in "alter_fk_users", select: p.id
PoolRepo.insert_all("alter_fk_posts", [[alter_fk_user_id: id]])
PoolRepo.delete_all("alter_fk_users")
assert [nil] == PoolRepo.all from p in "alter_fk_posts", select: p.alter_fk_user_id
:ok = down(PoolRepo, num, AlterForeignKeyOnDeleteMigration, log: false)
end
@tag :assigns_id_type
test "modify foreign key's on_update constraint", %{migration_number: num} do
assert :ok == up(PoolRepo, num, AlterForeignKeyOnUpdateMigration, log: false)
PoolRepo.insert_all("alter_fk_users", [[]])
assert [id] = PoolRepo.all from p in "alter_fk_users", select: p.id
PoolRepo.insert_all("alter_fk_posts", [[alter_fk_user_id: id]])
PoolRepo.update_all("alter_fk_users", set: [id: 12345])
assert [12345] == PoolRepo.all from p in "alter_fk_posts", select: p.alter_fk_user_id
PoolRepo.delete_all("alter_fk_posts")
:ok = down(PoolRepo, num, AlterForeignKeyOnUpdateMigration, log: false)
end
@tag :remove_column
test "remove column", %{migration_number: num} do
assert :ok == up(PoolRepo, num, DropColumnMigration, log: false)
assert catch_error(PoolRepo.all from p in "drop_col_migration", select: p.to_be_removed)
:ok = down(PoolRepo, num, DropColumnMigration, log: false)
end
@tag :rename_column
test "rename column", %{migration_number: num} do
assert :ok == up(PoolRepo, num, RenameColumnMigration, log: false)
assert [1] == PoolRepo.all from p in "rename_col_migration", select: p.was_renamed
:ok = down(PoolRepo, num, RenameColumnMigration, log: false)
end
@tag :rename_table
test "rename table", %{migration_number: num} do
assert :ok == up(PoolRepo, num, RenameMigration, log: false)
assert :ok == down(PoolRepo, num, RenameMigration, log: false)
end
@tag :prefix
test "prefix", %{migration_number: num} do
assert :ok == up(PoolRepo, num, PrefixMigration, log: false)
assert :ok == down(PoolRepo, num, PrefixMigration, log: false)
end
@tag :alter_primary_key
test "alter primary key", %{migration_number: num} do
assert :ok == up(PoolRepo, num, AlterPrimaryKeyMigration, log: false)
assert :ok == down(PoolRepo, num, AlterPrimaryKeyMigration, log: false)
end
@tag :add_column_if_not_exists
@tag :remove_column_if_exists
test "add if not exists and remove if exists does not raise on failure", %{migration_number: num} do
assert :ok == up(PoolRepo, num, NoErrorOnConditionalColumnMigration, log: false)
assert :ok == down(PoolRepo, num, NoErrorOnConditionalColumnMigration, log: false)
end
@tag :add_column_if_not_exists
test "add column if not exists", %{migration_number: num} do
assert :ok == up(PoolRepo, num, AddColumnIfNotExistsMigration, log: false)
assert [2] == PoolRepo.all from p in "add_col_if_not_exists_migration", select: p.to_be_added
:ok = down(PoolRepo, num, AddColumnIfNotExistsMigration, log: false)
end
@tag :remove_column_if_exists
test "remove column when exists", %{migration_number: num} do
assert :ok == up(PoolRepo, num, DropColumnIfExistsMigration, log: false)
assert catch_error(PoolRepo.all from p in "drop_col_if_exists_migration", select: p.to_be_removed)
:ok = down(PoolRepo, num, DropColumnIfExistsMigration, log: false)
end
@tag :on_delete_nilify_column_list
test "nilify list of columns on_delete constraint", %{migration_number: num} do
assert :ok == up(PoolRepo, num, OnDeleteNilifyColumnsMigration, log: false)
PoolRepo.insert_all("parent", [%{col1: 1, col2: 2}])
assert [{id, col1, col2}] = PoolRepo.all from p in "parent", select: {p.id, p.col1, p.col2}
PoolRepo.insert_all("ref", [[parent_id: id, col1: col1, col2: col2]])
PoolRepo.delete_all("parent")
assert [{nil, col1, nil}] == PoolRepo.all from r in "ref", select: {r.parent_id, r.col1, r.col2}
:ok = down(PoolRepo, num, OnDeleteNilifyColumnsMigration, log: false)
end
@tag :on_delete_default_all
test "default all on_delete constraint", %{migration_number: num} do
assert :ok == up(PoolRepo, num, OnDeleteDefaultAllMigration, log: false)
PoolRepo.insert_all("parent", [%{id: 1, col1: 2, col2: 3}])
{id, col1, col2} = {Enum.random(10..1000), Enum.random(10..1000), Enum.random(10..1000)}
PoolRepo.insert_all("parent", [%{id: id, col1: col1, col2: col2}])
PoolRepo.insert_all("ref", [%{parent_id: id, col1: col1, col2: col2}])
PoolRepo.delete_all(from p in "parent", where: p.id == ^id)
assert [{1, 2, 3}] == PoolRepo.all from r in "ref", select: {r.parent_id, r.col1, r.col2}
:ok = down(PoolRepo, num, OnDeleteDefaultAllMigration, log: false)
end
@tag :on_delete_default_column_list
test "default list of columns on_delete constraint", %{migration_number: num} do
assert :ok == up(PoolRepo, num, OnDeleteDefaultColumnsMigration, log: false)
PoolRepo.insert_all("parent", [%{id: 1, col1: 20, col2: 3}])
{id, col2} = {Enum.random(10..1000), Enum.random(10..1000)}
PoolRepo.insert_all("parent", [%{id: id, col1: 20, col2: col2}])
PoolRepo.insert_all("ref", [%{parent_id: id, col1: 20, col2: col2}])
PoolRepo.delete_all(from p in "parent", where: p.id == ^id)
assert [{1, 20, 3}] == PoolRepo.all from r in "ref", select: {r.parent_id, r.col1, r.col2}
:ok = down(PoolRepo, num, OnDeleteDefaultColumnsMigration, log: false)
end
end

View File

@@ -0,0 +1,264 @@
Code.require_file "../support/file_helpers.exs", __DIR__
defmodule Ecto.Integration.MigratorTest do
use Ecto.Integration.Case
import Support.FileHelpers
import ExUnit.CaptureLog
import Ecto.Migrator
alias Ecto.Integration.{TestRepo, PoolRepo}
alias Ecto.Migration.SchemaMigration
setup config do
Process.register(self(), config.test)
PoolRepo.delete_all(SchemaMigration)
:ok
end
defmodule AnotherSchemaMigration do
use Ecto.Migration
def change do
execute TestRepo.create_prefix("bad_schema_migrations"),
TestRepo.drop_prefix("bad_schema_migrations")
create table(:schema_migrations, prefix: "bad_schema_migrations") do
add :version, :string
add :inserted_at, :integer
end
end
end
defmodule BrokenLinkMigration do
use Ecto.Migration
def change do
Task.start_link(fn -> raise "oops" end)
Process.sleep(:infinity)
end
end
defmodule GoodMigration do
use Ecto.Migration
def up do
create table(:good_migration)
end
def down do
drop table(:good_migration)
end
end
defmodule BadMigration do
use Ecto.Migration
def change do
execute "CREATE WHAT"
end
end
test "migrations up and down" do
assert migrated_versions(PoolRepo) == []
assert up(PoolRepo, 31, GoodMigration, log: false) == :ok
[migration] = PoolRepo.all(SchemaMigration)
assert migration.version == 31
assert migration.inserted_at
assert migrated_versions(PoolRepo) == [31]
assert up(PoolRepo, 31, GoodMigration, log: false) == :already_up
assert migrated_versions(PoolRepo) == [31]
assert down(PoolRepo, 32, GoodMigration, log: false) == :already_down
assert migrated_versions(PoolRepo) == [31]
assert down(PoolRepo, 31, GoodMigration, log: false) == :ok
assert migrated_versions(PoolRepo) == []
end
@tag :prefix
test "does not commit migration if insert into schema migration fails" do
# First we create a new schema migration table in another prefix
assert up(PoolRepo, 33, AnotherSchemaMigration, log: false) == :ok
assert migrated_versions(PoolRepo) == [33]
catch_error(up(PoolRepo, 34, GoodMigration, log: false, prefix: "bad_schema_migrations"))
catch_error(PoolRepo.all("good_migration"))
catch_error(PoolRepo.all("good_migration", prefix: "bad_schema_migrations"))
assert down(PoolRepo, 33, AnotherSchemaMigration, log: false) == :ok
end
test "ecto-generated migration queries pass schema_migration in telemetry options" do
handler = fn _event_name, _measurements, metadata ->
send(self(), metadata)
end
# migration table creation
Process.put(:telemetry, handler)
migrated_versions(PoolRepo, log: false)
assert_received %{options: [schema_migration: true]}
# transaction begin statement
Process.put(:telemetry, handler)
migrated_versions(PoolRepo, skip_table_creation: true, log: false)
assert_received %{options: [schema_migration: true]}
# retrieving the migration versions
Process.put(:telemetry, handler)
migrated_versions(PoolRepo, migration_lock: false, skip_table_creation: true, log: false)
assert_received %{options: [schema_migration: true]}
end
test "bad execute migration" do
assert catch_error(up(PoolRepo, 31, BadMigration, log: false))
assert DynamicSupervisor.which_children(Ecto.MigratorSupervisor) == []
end
test "broken link migration" do
Process.flag(:trap_exit, true)
assert capture_log(fn ->
{:ok, pid} = Task.start_link(fn -> up(PoolRepo, 31, BrokenLinkMigration, log: false) end)
assert_receive {:EXIT, ^pid, _}
end) =~ "oops"
assert capture_log(fn ->
catch_exit(up(PoolRepo, 31, BrokenLinkMigration, log: false))
end) =~ "oops"
end
test "run up to/step migration", config do
in_tmp fn path ->
create_migration(47, config)
create_migration(48, config)
assert [47] = run(PoolRepo, path, :up, step: 1, log: false)
assert count_entries() == 1
assert [48] = run(PoolRepo, path, :up, to: 48, log: false)
end
end
test "run down to/step migration", config do
in_tmp fn path ->
migrations = [
create_migration(49, config),
create_migration(50, config),
]
assert [49, 50] = run(PoolRepo, path, :up, all: true, log: false)
purge migrations
assert [50] = run(PoolRepo, path, :down, step: 1, log: false)
purge migrations
assert count_entries() == 1
assert [50] = run(PoolRepo, path, :up, to: 50, log: false)
end
end
test "runs all migrations", config do
in_tmp fn path ->
migrations = [
create_migration(53, config),
create_migration(54, config),
]
assert [53, 54] = run(PoolRepo, path, :up, all: true, log: false)
assert [] = run(PoolRepo, path, :up, all: true, log: false)
purge migrations
assert [54, 53] = run(PoolRepo, path, :down, all: true, log: false)
purge migrations
assert count_entries() == 0
assert [53, 54] = run(PoolRepo, path, :up, all: true, log: false)
end
end
test "does not commit half transactions on bad syntax", config do
in_tmp fn path ->
migrations = [
create_migration(64, config),
create_migration("65_+", config)
]
assert_raise SyntaxError, fn ->
run(PoolRepo, path, :up, all: true, log: false)
end
refute_received {:up, _}
assert count_entries() == 0
purge migrations
end
end
@tag :lock_for_migrations
test "raises when connection pool is too small" do
config = Application.fetch_env!(:ecto_sql, PoolRepo)
config = Keyword.merge(config, pool_size: 1)
Application.put_env(:ecto_sql, __MODULE__.SingleConnectionRepo, config)
defmodule SingleConnectionRepo do
use Ecto.Repo, otp_app: :ecto_sql, adapter: PoolRepo.__adapter__()
end
{:ok, _pid} = SingleConnectionRepo.start_link()
in_tmp fn path ->
exception_message = ~r/Migrations failed to run because the connection pool size is less than 2/
assert_raise Ecto.MigrationError, exception_message, fn ->
run(SingleConnectionRepo, path, :up, all: true, log: false)
end
end
end
test "does not raise when connection pool is too small but there is no lock" do
config = Application.fetch_env!(:ecto_sql, PoolRepo)
config = Keyword.merge(config, pool_size: 1, migration_lock: nil)
Application.put_env(:ecto_sql, __MODULE__.SingleConnectionNoLockRepo, config)
defmodule SingleConnectionNoLockRepo do
use Ecto.Repo, otp_app: :ecto_sql, adapter: PoolRepo.__adapter__()
end
{:ok, _pid} = SingleConnectionNoLockRepo.start_link()
in_tmp fn path ->
run(SingleConnectionNoLockRepo, path, :up, all: true, log: false)
end
end
defp count_entries() do
PoolRepo.aggregate(SchemaMigration, :count, :version)
end
defp create_migration(num, config) do
module = Module.concat(__MODULE__, "Migration#{num}")
File.write! "#{num}_migration_#{num}.exs", """
defmodule #{module} do
use Ecto.Migration
def up do
send #{inspect config.test}, {:up, #{inspect num}}
end
def down do
send #{inspect config.test}, {:down, #{inspect num}}
end
end
"""
module
end
defp purge(modules) do
Enum.each(List.wrap(modules), fn m ->
:code.delete m
:code.purge m
end)
end
end

View File

@@ -0,0 +1,15 @@
defmodule Ecto.Integration.QueryManyTest do
use Ecto.Integration.Case, async: true
alias Ecto.Integration.TestRepo
test "query_many!/4" do
results = TestRepo.query_many!("SELECT 1; SELECT 2;")
assert [%{rows: [[1]], num_rows: 1}, %{rows: [[2]], num_rows: 1}] = results
end
test "query_many!/4 with iodata" do
results = TestRepo.query_many!(["SELECT", ?\s, ?1, ";", ?\s, "SELECT", ?\s, ?2, ";"])
assert [%{rows: [[1]], num_rows: 1}, %{rows: [[2]], num_rows: 1}] = results
end
end

View File

@@ -0,0 +1,316 @@
defmodule Ecto.Integration.SandboxTest do
use ExUnit.Case
alias Ecto.Adapters.SQL.Sandbox
alias Ecto.Integration.{PoolRepo, TestRepo}
alias Ecto.Integration.Post
import ExUnit.CaptureLog
Application.put_env(:ecto_sql, __MODULE__.DynamicRepo, Application.compile_env(:ecto_sql, TestRepo))
defmodule DynamicRepo do
use Ecto.Repo, otp_app: :ecto_sql, adapter: TestRepo.__adapter__()
end
describe "errors" do
test "raises if repo doesn't exist" do
assert_raise UndefinedFunctionError, ~r"function UnknownRepo.get_dynamic_repo/0 is undefined", fn ->
Sandbox.mode(UnknownRepo, :manual)
end
end
test "raises if repo is not started" do
assert_raise RuntimeError, ~r"could not lookup Ecto repo #{inspect DynamicRepo} because it was not started", fn ->
Sandbox.mode(DynamicRepo, :manual)
end
end
test "raises if repo is not using sandbox" do
assert_raise RuntimeError, ~r"cannot invoke sandbox operation with pool DBConnection", fn ->
Sandbox.mode(PoolRepo, :manual)
end
assert_raise RuntimeError, ~r"cannot invoke sandbox operation with pool DBConnection", fn ->
Sandbox.checkout(PoolRepo)
end
end
test "includes link to SQL sandbox on ownership errors" do
assert_raise DBConnection.OwnershipError,
~r"See Ecto.Adapters.SQL.Sandbox docs for more information.", fn ->
TestRepo.all(Post)
end
end
end
describe "mode" do
test "uses the repository when checked out" do
assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn ->
TestRepo.all(Post)
end
Sandbox.checkout(TestRepo)
assert TestRepo.all(Post) == []
Sandbox.checkin(TestRepo)
assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn ->
TestRepo.all(Post)
end
end
test "uses the repository when allowed from another process" do
assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn ->
TestRepo.all(Post)
end
parent = self()
Task.start_link fn ->
Sandbox.checkout(TestRepo)
Sandbox.allow(TestRepo, self(), parent)
send(parent, :allowed)
Process.sleep(:infinity)
end
assert_receive :allowed
assert TestRepo.all(Post) == []
end
test "uses the repository when allowed from another process by registered name" do
assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn ->
TestRepo.all(Post)
end
parent = self()
Process.register(parent, __MODULE__)
Task.start_link fn ->
Sandbox.checkout(TestRepo)
Sandbox.allow(TestRepo, self(), __MODULE__)
send(parent, :allowed)
Process.sleep(:infinity)
end
assert_receive :allowed
assert TestRepo.all(Post) == []
Process.unregister(__MODULE__)
end
test "uses the repository when shared from another process" do
assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn ->
TestRepo.all(Post)
end
parent = self()
Task.start_link(fn ->
Sandbox.checkout(TestRepo)
Sandbox.mode(TestRepo, {:shared, self()})
send(parent, :shared)
Process.sleep(:infinity)
end)
assert_receive :shared
assert Task.async(fn -> TestRepo.all(Post) end) |> Task.await == []
after
Sandbox.mode(TestRepo, :manual)
end
test "works with a dynamic repo" do
repo_pid = start_supervised!({DynamicRepo, name: nil})
DynamicRepo.put_dynamic_repo(repo_pid)
assert Sandbox.mode(DynamicRepo, :manual) == :ok
assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn ->
DynamicRepo.all(Post)
end
Sandbox.checkout(DynamicRepo)
assert DynamicRepo.all(Post) == []
end
test "works with a repo pid" do
repo_pid = start_supervised!({DynamicRepo, name: nil})
DynamicRepo.put_dynamic_repo(repo_pid)
assert Sandbox.mode(repo_pid, :manual) == :ok
assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn ->
DynamicRepo.all(Post)
end
Sandbox.checkout(repo_pid)
assert DynamicRepo.all(Post) == []
end
end
describe "savepoints" do
test "runs inside a sandbox that is rolled back on checkin" do
Sandbox.checkout(TestRepo)
assert TestRepo.insert(%Post{})
assert TestRepo.all(Post) != []
Sandbox.checkin(TestRepo)
Sandbox.checkout(TestRepo)
assert TestRepo.all(Post) == []
Sandbox.checkin(TestRepo)
end
test "runs inside a sandbox that may be disabled" do
Sandbox.checkout(TestRepo, sandbox: false)
assert TestRepo.insert(%Post{})
assert TestRepo.all(Post) != []
Sandbox.checkin(TestRepo)
Sandbox.checkout(TestRepo)
assert {1, _} = TestRepo.delete_all(Post)
Sandbox.checkin(TestRepo)
Sandbox.checkout(TestRepo, sandbox: false)
assert {1, _} = TestRepo.delete_all(Post)
Sandbox.checkin(TestRepo)
end
test "runs inside a sandbox with caller data when preloading associations" do
Sandbox.checkout(TestRepo)
assert TestRepo.insert(%Post{})
parent = self()
Task.start_link fn ->
Sandbox.allow(TestRepo, parent, self())
assert [_] = TestRepo.all(Post) |> TestRepo.preload([:author, :comments])
send parent, :success
end
assert_receive :success
end
test "runs inside a sidebox with custom ownership timeout" do
:ok = Sandbox.checkout(TestRepo, ownership_timeout: 200)
parent = self()
assert capture_log(fn ->
{:ok, pid} =
Task.start(fn ->
Sandbox.allow(TestRepo, parent, self())
TestRepo.transaction(fn -> Process.sleep(500) end)
end)
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, _, ^pid, _}, 1000
end) =~ "it owned the connection for longer than 200ms"
end
test "does not taint the sandbox on query errors" do
Sandbox.checkout(TestRepo)
{:ok, _} = TestRepo.insert(%Post{}, skip_transaction: true)
{:error, _} = TestRepo.query("INVALID")
{:ok, _} = TestRepo.insert(%Post{}, skip_transaction: true)
Sandbox.checkin(TestRepo)
end
end
describe "transactions" do
@tag :transaction_isolation
test "with custom isolation level" do
Sandbox.checkout(TestRepo, isolation: "READ UNCOMMITTED")
# Setting it to the same level later on works
TestRepo.query!("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")
# Even inside a transaction
TestRepo.transaction fn ->
TestRepo.query!("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")
end
end
test "disconnects on transaction timeouts" do
Sandbox.checkout(TestRepo)
assert capture_log(fn ->
{:error, :rollback} =
TestRepo.transaction(fn -> Process.sleep(1000) end, timeout: 100)
end) =~ "timed out"
Sandbox.checkin(TestRepo)
end
end
describe "checkouts" do
test "with transaction inside checkout" do
Sandbox.checkout(TestRepo)
refute TestRepo.checked_out?()
refute TestRepo.in_transaction?()
TestRepo.checkout(fn ->
assert TestRepo.checked_out?()
refute TestRepo.in_transaction?()
TestRepo.transaction(fn ->
assert TestRepo.checked_out?()
assert TestRepo.in_transaction?()
end)
assert TestRepo.checked_out?()
refute TestRepo.in_transaction?()
end)
refute TestRepo.checked_out?()
refute TestRepo.in_transaction?()
end
test "with checkout inside transaction" do
Sandbox.checkout(TestRepo)
refute TestRepo.checked_out?()
refute TestRepo.in_transaction?()
TestRepo.transaction(fn ->
assert TestRepo.checked_out?()
assert TestRepo.in_transaction?()
TestRepo.checkout(fn ->
assert TestRepo.checked_out?()
assert TestRepo.in_transaction?()
end)
assert TestRepo.checked_out?()
assert TestRepo.in_transaction?()
end)
refute TestRepo.checked_out?()
refute TestRepo.in_transaction?()
end
end
describe "start_owner!/2" do
test "checks out the connection" do
assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn ->
TestRepo.all(Post)
end
owner = Sandbox.start_owner!(TestRepo)
assert TestRepo.all(Post) == []
:ok = Sandbox.stop_owner(owner)
refute Process.alive?(owner)
end
test "can set shared mode" do
assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn ->
TestRepo.all(Post)
end
parent = self()
Task.start_link(fn ->
owner = Sandbox.start_owner!(TestRepo, shared: true)
send(parent, {:owner, owner})
Process.sleep(:infinity)
end)
assert_receive {:owner, owner}
assert TestRepo.all(Post) == []
:ok = Sandbox.stop_owner(owner)
after
Sandbox.mode(TestRepo, :manual)
end
end
end

View File

@@ -0,0 +1,178 @@
defmodule Ecto.Integration.SQLTest do
use Ecto.Integration.Case, async: Application.compile_env(:ecto, :async_integration_tests, true)
alias Ecto.Integration.PoolRepo
alias Ecto.Integration.TestRepo
alias Ecto.Integration.Barebone
alias Ecto.Integration.Post
alias Ecto.Integration.CorruptedPk
alias Ecto.Integration.Tag
import Ecto.Query, only: [from: 2]
test "fragmented types" do
datetime = ~N[2014-01-16 20:26:51]
TestRepo.insert!(%Post{inserted_at: datetime})
query = from p in Post, where: fragment("? >= ?", p.inserted_at, ^datetime), select: p.inserted_at
assert [^datetime] = TestRepo.all(query)
end
test "fragmented schemaless types" do
TestRepo.insert!(%Post{visits: 123})
assert [123] = TestRepo.all(from p in "posts", select: type(fragment("visits"), :integer))
end
test "type casting negative integers" do
TestRepo.insert!(%Post{visits: -42})
assert [-42] = TestRepo.all(from(p in Post, select: type(p.visits, :integer)))
end
@tag :array_type
test "fragment array types" do
text1 = "foo"
text2 = "bar"
result = TestRepo.query!("SELECT $1::text[]", [[text1, text2]])
assert result.rows == [[[text1, text2]]]
end
@tag :array_type
test "Converts empty array correctly" do
result = TestRepo.query!("SELECT array[1,2,3] = $1", [[]])
assert result.rows == [[false]]
result = TestRepo.query!("SELECT array[]::integer[] = $1", [[]])
assert result.rows == [[true]]
%{id: tag_id} = TestRepo.insert!(%Tag{uuids: []})
query = from t in Tag, where: t.uuids == []
assert [%{id: ^tag_id}] = TestRepo.all(query)
end
test "query!/4 with dynamic repo" do
TestRepo.put_dynamic_repo(:unknown)
assert_raise RuntimeError, ~r/:unknown/, fn -> TestRepo.query!("SELECT 1") end
end
test "query!/4" do
result = TestRepo.query!("SELECT 1")
assert result.rows == [[1]]
end
test "query!/4 with iodata" do
result = TestRepo.query!(["SELECT", ?\s, ?1])
assert result.rows == [[1]]
end
test "disconnect_all/2" do
assert :ok = PoolRepo.disconnect_all(0)
end
test "to_sql/3" do
{sql, []} = TestRepo.to_sql(:all, Barebone)
assert sql =~ "SELECT"
assert sql =~ "barebones"
{sql, [0]} = TestRepo.to_sql(:update_all, from(b in Barebone, update: [set: [num: ^0]]))
assert sql =~ "UPDATE"
assert sql =~ "barebones"
assert sql =~ "SET"
{sql, []} = TestRepo.to_sql(:delete_all, Barebone)
assert sql =~ "DELETE"
assert sql =~ "barebones"
end
test "raises when primary key is not unique on struct operation" do
schema = %CorruptedPk{a: "abc"}
TestRepo.insert!(schema)
TestRepo.insert!(schema)
TestRepo.insert!(schema)
assert_raise Ecto.MultiplePrimaryKeyError,
~r|expected delete on corrupted_pk to return at most one entry but got 3 entries|,
fn -> TestRepo.delete!(schema) end
end
test "Repo.insert! escape" do
TestRepo.insert!(%Post{title: "'"})
query = from(p in Post, select: p.title)
assert ["'"] == TestRepo.all(query)
end
test "Repo.update! escape" do
p = TestRepo.insert!(%Post{title: "hello"})
TestRepo.update!(Ecto.Changeset.change(p, title: "'"))
query = from(p in Post, select: p.title)
assert ["'"] == TestRepo.all(query)
end
@tag :insert_cell_wise_defaults
test "Repo.insert_all escape" do
TestRepo.insert_all(Post, [%{title: "'"}])
query = from(p in Post, select: p.title)
assert ["'"] == TestRepo.all(query)
end
test "Repo.update_all escape" do
TestRepo.insert!(%Post{title: "hello"})
TestRepo.update_all(Post, set: [title: "'"])
reader = from(p in Post, select: p.title)
assert ["'"] == TestRepo.all(reader)
query = from(Post, where: "'" != "")
TestRepo.update_all(query, set: [title: "''"])
assert ["''"] == TestRepo.all(reader)
end
test "Repo.delete_all escape" do
TestRepo.insert!(%Post{title: "hello"})
assert [_] = TestRepo.all(Post)
TestRepo.delete_all(from(Post, where: "'" == "'"))
assert [] == TestRepo.all(Post)
end
test "load" do
inserted_at = ~N[2016-01-01 09:00:00]
TestRepo.insert!(%Post{title: "title1", inserted_at: inserted_at, public: false})
result = Ecto.Adapters.SQL.query!(TestRepo, "SELECT * FROM posts", [])
posts = Enum.map(result.rows, &TestRepo.load(Post, {result.columns, &1}))
assert [%Post{title: "title1", inserted_at: ^inserted_at, public: false}] = posts
end
test "returns true when table exists" do
assert Ecto.Adapters.SQL.table_exists?(TestRepo, "posts")
end
test "returns false table doesn't exists" do
refute Ecto.Adapters.SQL.table_exists?(TestRepo, "unknown")
end
test "returns result as a formatted table" do
TestRepo.insert_all(Post, [%{title: "my post title", counter: 1, public: nil}])
# resolve correct query for each adapter
query = from(p in Post, select: [p.title, p.counter, p.public])
{query, _} = Ecto.Adapters.SQL.to_sql(:all, TestRepo, query)
table =
query
|> TestRepo.query!()
|> Ecto.Adapters.SQL.format_table()
assert table == "+---------------+---------+--------+\n| title | counter | public |\n+---------------+---------+--------+\n| my post title | 1 | NULL |\n+---------------+---------+--------+"
end
test "format_table edge cases" do
assert Ecto.Adapters.SQL.format_table(nil) == ""
assert Ecto.Adapters.SQL.format_table(%{columns: nil, rows: nil}) == ""
assert Ecto.Adapters.SQL.format_table(%{columns: [], rows: []}) == ""
assert Ecto.Adapters.SQL.format_table(%{columns: [], rows: [["test"]]}) == ""
assert Ecto.Adapters.SQL.format_table(%{columns: ["test"], rows: []}) == "+------+\n| test |\n+------+\n+------+"
assert Ecto.Adapters.SQL.format_table(%{columns: ["test"], rows: nil}) == "+------+\n| test |\n+------+\n+------+"
end
end

View File

@@ -0,0 +1,44 @@
defmodule Ecto.Integration.StreamTest do
use Ecto.Integration.Case, async: Application.compile_env(:ecto, :async_integration_tests, true)
alias Ecto.Integration.TestRepo
alias Ecto.Integration.Post
alias Ecto.Integration.Comment
import Ecto.Query
test "stream empty" do
assert {:ok, []} = TestRepo.transaction(fn() ->
TestRepo.stream(Post)
|> Enum.to_list()
end)
assert {:ok, []} = TestRepo.transaction(fn() ->
TestRepo.stream(from p in Post)
|> Enum.to_list()
end)
end
test "stream without schema" do
%Post{} = TestRepo.insert!(%Post{title: "title1"})
%Post{} = TestRepo.insert!(%Post{title: "title2"})
assert {:ok, ["title1", "title2"]} = TestRepo.transaction(fn() ->
TestRepo.stream(from(p in "posts", order_by: p.title, select: p.title))
|> Enum.to_list()
end)
end
test "stream with assoc" do
p1 = TestRepo.insert!(%Post{title: "1"})
%Comment{id: cid1} = TestRepo.insert!(%Comment{text: "1", post_id: p1.id})
%Comment{id: cid2} = TestRepo.insert!(%Comment{text: "2", post_id: p1.id})
stream = TestRepo.stream(Ecto.assoc(p1, :comments))
assert {:ok, [c1, c2]} = TestRepo.transaction(fn() ->
Enum.to_list(stream)
end)
assert c1.id == cid1
assert c2.id == cid2
end
end

View File

@@ -0,0 +1,153 @@
defmodule Ecto.Integration.SubQueryTest do
use Ecto.Integration.Case, async: Application.compile_env(:ecto, :async_integration_tests, true)
alias Ecto.Integration.TestRepo
import Ecto.Query
alias Ecto.Integration.Post
alias Ecto.Integration.Comment
test "from: subqueries with select source" do
TestRepo.insert!(%Post{title: "hello", public: true})
query = from p in Post, select: p
assert ["hello"] =
TestRepo.all(from p in subquery(query), select: p.title)
assert [post] =
TestRepo.all(from p in subquery(query), select: p)
assert %NaiveDateTime{} = post.inserted_at
assert post.__meta__.state == :loaded
end
@tag :map_boolean_in_expression
test "from: subqueries with map and select expression" do
TestRepo.insert!(%Post{title: "hello", public: true})
query = from p in Post, select: %{title: p.title, pub: not p.public}
assert ["hello"] =
TestRepo.all(from p in subquery(query), select: p.title)
assert [%{title: "hello", pub: false}] =
TestRepo.all(from p in subquery(query), select: p)
assert [{"hello", %{title: "hello", pub: false}}] =
TestRepo.all(from p in subquery(query), select: {p.title, p})
assert [{%{title: "hello", pub: false}, false}] =
TestRepo.all(from p in subquery(query), select: {p, p.pub})
end
@tag :map_boolean_in_expression
test "from: subqueries with map update and select expression" do
TestRepo.insert!(%Post{title: "hello", public: true})
query = from p in Post, select: %{p | public: not p.public}
assert ["hello"] =
TestRepo.all(from p in subquery(query), select: p.title)
assert [%Post{title: "hello", public: false}] =
TestRepo.all(from p in subquery(query), select: p)
assert [{"hello", %Post{title: "hello", public: false}}] =
TestRepo.all(from p in subquery(query), select: {p.title, p})
assert [{%Post{title: "hello", public: false}, false}] =
TestRepo.all(from p in subquery(query), select: {p, p.public})
end
test "from: subqueries with map update on virtual field and select expression" do
TestRepo.insert!(%Post{title: "hello"})
query = from p in Post, select: %{p | temp: p.title}
assert ["hello"] =
TestRepo.all(from p in subquery(query), select: p.temp)
assert [%Post{title: "hello", temp: "hello"}] =
TestRepo.all(from p in subquery(query), select: p)
end
@tag :subquery_aggregates
test "from: subqueries with aggregates" do
TestRepo.insert!(%Post{visits: 10})
TestRepo.insert!(%Post{visits: 11})
TestRepo.insert!(%Post{visits: 13})
query = from p in Post, select: [:visits], order_by: [asc: :visits]
assert [13] = TestRepo.all(from p in subquery(query), select: max(p.visits))
query = from p in Post, select: [:visits], order_by: [asc: :visits], limit: 2
assert [11] = TestRepo.all(from p in subquery(query), select: max(p.visits))
query = from p in Post, order_by: [asc: :visits]
assert [13] = TestRepo.all(from p in subquery(query), select: max(p.visits))
query = from p in Post, order_by: [asc: :visits], limit: 2
assert [11] = TestRepo.all(from p in subquery(query), select: max(p.visits))
end
test "from: subqueries with parameters" do
TestRepo.insert!(%Post{visits: 10, title: "hello"})
TestRepo.insert!(%Post{visits: 11, title: "hello"})
TestRepo.insert!(%Post{visits: 13, title: "world"})
query = from p in Post, where: p.visits >= ^11 and p.visits <= ^13
query = from p in subquery(query), where: p.title == ^"hello", select: fragment("? + ?", p.visits, ^1)
assert [12] = TestRepo.all(query)
end
test "join: subqueries with select source" do
%{id: id} = TestRepo.insert!(%Post{title: "hello", public: true})
TestRepo.insert!(%Comment{post_id: id})
query = from p in Post, select: p
assert ["hello"] =
TestRepo.all(from c in Comment, join: p in subquery(query), on: c.post_id == p.id, select: p.title)
assert [%Post{inserted_at: %NaiveDateTime{}}] =
TestRepo.all(from c in Comment, join: p in subquery(query), on: c.post_id == p.id, select: p)
end
test "join: subqueries with parameters" do
TestRepo.insert!(%Post{visits: 10, title: "hello"})
TestRepo.insert!(%Post{visits: 11, title: "hello"})
TestRepo.insert!(%Post{visits: 13, title: "world"})
TestRepo.insert!(%Comment{})
TestRepo.insert!(%Comment{})
query = from p in Post, where: p.visits >= ^11 and p.visits <= ^13
query = from c in Comment,
join: p in subquery(query),
on: true,
where: p.title == ^"hello",
select: fragment("? + ?", p.visits, ^1)
assert [12, 12] = TestRepo.all(query)
end
@tag :subquery_in_order_by
test "subqueries in order by" do
TestRepo.insert!(%Post{visits: 10, title: "hello"})
TestRepo.insert!(%Post{visits: 11, title: "hello"})
query = from p in Post, as: :p, order_by: [asc: exists(from p in Post, where: p.visits > parent_as(:p).visits)]
assert [%{visits: 11}, %{visits: 10}] = TestRepo.all(query)
end
@tag :multicolumn_distinct
@tag :subquery_in_distinct
test "subqueries in distinct" do
TestRepo.insert!(%Post{visits: 10, title: "hello1"})
TestRepo.insert!(%Post{visits: 10, title: "hello2"})
TestRepo.insert!(%Post{visits: 11, title: "hello"})
query = from p in Post, as: :p, distinct: exists(from p in Post, where: p.visits > parent_as(:p).visits), order_by: [asc: :title]
assert [%{title: "hello"}, %{title: "hello1"}] = TestRepo.all(query)
end
@tag :subquery_in_group_by
test "subqueries in group by" do
TestRepo.insert!(%Post{visits: 10, title: "hello1"})
TestRepo.insert!(%Post{visits: 10, title: "hello2"})
TestRepo.insert!(%Post{visits: 11, title: "hello"})
query = from p in Post, as: :p, select: sum(p.visits), group_by: exists(from p in Post, where: p.visits > parent_as(:p).visits), order_by: [sum(p.visits)]
query
|> TestRepo.all()
|> Enum.map(&Decimal.new/1)
|> Enum.zip([Decimal.new(11), Decimal.new(20)])
|> Enum.all?(fn {a, b} -> Decimal.eq?(a, b) end)
|> assert()
end
end

View File

@@ -0,0 +1,277 @@
defmodule Ecto.Integration.TransactionTest do
# We can keep this test async as long as it
# is the only one access the transactions table
use Ecto.Integration.Case, async: true
import Ecto.Query
alias Ecto.Integration.PoolRepo # Used for writes
alias Ecto.Integration.TestRepo # Used for reads
@moduletag :capture_log
defmodule UniqueError do
defexception message: "unique error"
end
setup do
PoolRepo.delete_all "transactions"
:ok
end
defmodule Trans do
use Ecto.Schema
schema "transactions" do
field :num, :integer
end
end
test "transaction returns value" do
refute PoolRepo.in_transaction?()
{:ok, val} = PoolRepo.transaction(fn ->
assert PoolRepo.in_transaction?()
{:ok, val} =
PoolRepo.transaction(fn ->
assert PoolRepo.in_transaction?()
42
end)
assert PoolRepo.in_transaction?()
val
end)
refute PoolRepo.in_transaction?()
assert val == 42
end
test "transaction re-raises" do
assert_raise UniqueError, fn ->
PoolRepo.transaction(fn ->
PoolRepo.transaction(fn ->
raise UniqueError
end)
end)
end
end
# tag is required for TestRepo, since it is checkout in
# Ecto.Integration.Case setup
@tag isolation_level: :snapshot
test "transaction commits" do
# mssql requires that all transactions that use same shared lock are set
# to :snapshot isolation level
opts = [isolation_level: :snapshot]
PoolRepo.transaction(fn ->
e = PoolRepo.insert!(%Trans{num: 1})
assert [^e] = PoolRepo.all(Trans)
assert [] = TestRepo.all(Trans)
end, opts)
assert [%Trans{num: 1}] = PoolRepo.all(Trans)
end
@tag isolation_level: :snapshot
test "transaction rolls back" do
opts = [isolation_level: :snapshot]
try do
PoolRepo.transaction(fn ->
e = PoolRepo.insert!(%Trans{num: 2})
assert [^e] = PoolRepo.all(Trans)
assert [] = TestRepo.all(Trans)
raise UniqueError
end, opts)
rescue
UniqueError -> :ok
end
assert [] = TestRepo.all(Trans)
end
test "transaction rolls back per repository" do
message = "cannot call rollback outside of transaction"
assert_raise RuntimeError, message, fn ->
PoolRepo.rollback(:done)
end
assert_raise RuntimeError, message, fn ->
TestRepo.transaction fn ->
PoolRepo.rollback(:done)
end
end
end
@tag :assigns_id_type
test "transaction rolls back with reason on aborted transaction" do
e1 = PoolRepo.insert!(%Trans{num: 13})
assert_raise Ecto.ConstraintError, fn ->
TestRepo.transaction fn ->
PoolRepo.insert!(%Trans{id: e1.id, num: 14})
end
end
end
test "nested transaction partial rollback" do
assert PoolRepo.transaction(fn ->
e1 = PoolRepo.insert!(%Trans{num: 3})
assert [^e1] = PoolRepo.all(Trans)
try do
PoolRepo.transaction(fn ->
e2 = PoolRepo.insert!(%Trans{num: 4})
assert [^e1, ^e2] = PoolRepo.all(from(t in Trans, order_by: t.num))
raise UniqueError
end)
rescue
UniqueError -> :ok
end
assert_raise DBConnection.ConnectionError, "transaction rolling back",
fn() -> PoolRepo.insert!(%Trans{num: 5}) end
end) == {:error, :rollback}
assert TestRepo.all(Trans) == []
end
test "manual rollback doesn't bubble up" do
x = PoolRepo.transaction(fn ->
e = PoolRepo.insert!(%Trans{num: 6})
assert [^e] = PoolRepo.all(Trans)
PoolRepo.rollback(:oops)
end)
assert x == {:error, :oops}
assert [] = TestRepo.all(Trans)
end
test "manual rollback bubbles up on nested transaction" do
assert PoolRepo.transaction(fn ->
e = PoolRepo.insert!(%Trans{num: 7})
assert [^e] = PoolRepo.all(Trans)
assert {:error, :oops} = PoolRepo.transaction(fn ->
PoolRepo.rollback(:oops)
end)
assert_raise DBConnection.ConnectionError, "transaction rolling back",
fn() -> PoolRepo.insert!(%Trans{num: 8}) end
end) == {:error, :rollback}
assert [] = TestRepo.all(Trans)
end
test "transactions are not shared in repo" do
pid = self()
opts = [isolation_level: :snapshot]
new_pid = spawn_link fn ->
PoolRepo.transaction(fn ->
e = PoolRepo.insert!(%Trans{num: 9})
assert [^e] = PoolRepo.all(Trans)
send(pid, :in_transaction)
receive do
:commit -> :ok
after
5000 -> raise "timeout"
end
end, opts)
send(pid, :committed)
end
receive do
:in_transaction -> :ok
after
5000 -> raise "timeout"
end
# mssql requires that all transactions that use same shared lock
# set transaction isolation level to "snapshot" so this must be wrapped into
# explicit transaction
PoolRepo.transaction(fn ->
assert [] = PoolRepo.all(Trans)
end, opts)
send(new_pid, :commit)
receive do
:committed -> :ok
after
5000 -> raise "timeout"
end
assert [%Trans{num: 9}] = PoolRepo.all(Trans)
end
## Checkout
describe "with checkouts" do
test "transaction inside checkout" do
PoolRepo.checkout(fn ->
refute PoolRepo.in_transaction?()
PoolRepo.transaction(fn ->
assert PoolRepo.in_transaction?()
end)
refute PoolRepo.in_transaction?()
end)
end
test "checkout inside transaction" do
PoolRepo.transaction(fn ->
assert PoolRepo.in_transaction?()
PoolRepo.checkout(fn ->
assert PoolRepo.in_transaction?()
end)
assert PoolRepo.in_transaction?()
end)
end
@tag :transaction_checkout_raises
test "checkout raises on transaction attempt" do
assert_raise DBConnection.ConnectionError, ~r"connection was checked out with status", fn ->
PoolRepo.checkout(fn -> PoolRepo.query!("BEGIN") end)
end
end
end
## Logging
defp register_telemetry() do
Process.put(:telemetry, fn _, measurements, event -> send(self(), {measurements, event}) end)
end
test "log begin, commit and rollback" do
register_telemetry()
PoolRepo.transaction(fn ->
assert_received {measurements, %{params: [], result: {:ok, _res}}}
assert is_integer(measurements.query_time) and measurements.query_time >= 0
assert is_integer(measurements.queue_time) and measurements.queue_time >= 0
refute_received %{}
register_telemetry()
end)
assert_received {measurements, %{params: [], result: {:ok, _res}}}
assert is_integer(measurements.query_time) and measurements.query_time >= 0
refute Map.has_key?(measurements, :queue_time)
assert PoolRepo.transaction(fn ->
refute_received %{}
register_telemetry()
PoolRepo.rollback(:log_rollback)
end) == {:error, :log_rollback}
assert_received {measurements, %{params: [], result: {:ok, _res}}}
assert is_integer(measurements.query_time) and measurements.query_time >= 0
refute Map.has_key?(measurements, :queue_time)
end
test "log queries inside transactions" do
PoolRepo.transaction(fn ->
register_telemetry()
assert [] = PoolRepo.all(Trans)
assert_received {measurements, %{params: [], result: {:ok, _res}}}
assert is_integer(measurements.query_time) and measurements.query_time >= 0
assert is_integer(measurements.decode_time) and measurements.query_time >= 0
refute Map.has_key?(measurements, :queue_time)
end)
end
end

View File

@@ -0,0 +1,45 @@
defmodule Support.FileHelpers do
import ExUnit.Assertions
@doc """
Returns the `tmp_path` for tests.
"""
def tmp_path do
Path.expand("../../tmp", __DIR__)
end
@doc """
Executes the given function in a temp directory
tailored for this test case and test.
"""
defmacro in_tmp(fun) do
{name, _arity} = __CALLER__.function || raise "in_tmp must be called inside a function"
path = Path.join([tmp_path(), "#{__CALLER__.module}", "#{name}"])
quote do
path = unquote(path)
File.rm_rf!(path)
File.mkdir_p!(path)
File.cd!(path, fn -> unquote(fun).(path) end)
end
end
@doc """
Asserts a file was generated.
"""
def assert_file(file) do
assert File.regular?(file), "Expected #{file} to exist, but does not"
end
@doc """
Asserts a file was generated and that it matches a given pattern.
"""
def assert_file(file, callback) when is_function(callback, 1) do
assert_file(file)
callback.(File.read!(file))
end
def assert_file(file, match) do
assert_file(file, &assert(&1 =~ match))
end
end

View File

@@ -0,0 +1,166 @@
defmodule Ecto.Integration.Migration do
use Ecto.Migration
def change do
# IO.puts "TESTING MIGRATION LOCK"
# Process.sleep(10000)
create table(:users, comment: "users table") do
add :name, :string, comment: "name column"
add :custom_id, :uuid
timestamps()
end
create table(:posts) do
add :title, :string, size: 100
add :counter, :integer
add :blob, :binary
add :bid, :binary_id
add :uuid, :uuid
add :meta, :map
add :links, {:map, :string}
add :intensities, {:map, :float}
add :public, :boolean
add :cost, :decimal, precision: 2, scale: 1
add :visits, :integer
add :wrapped_visits, :integer
add :intensity, :float
add :author_id, :integer
add :posted, :date
add :read_only, :string
timestamps(null: true)
end
create table(:posts_users, primary_key: false) do
add :post_id, references(:posts)
add :user_id, references(:users)
end
create table(:posts_users_pk) do
add :post_id, references(:posts)
add :user_id, references(:users)
timestamps()
end
# Add a unique index on uuid. We use this
# to verify the behaviour that the index
# only matters if the UUID column is not NULL.
create unique_index(:posts, [:uuid], comment: "posts index")
create table(:permalinks) do
add :uniform_resource_locator, :string
add :title, :string
add :post_id, references(:posts)
add :user_id, references(:users)
end
create unique_index(:permalinks, [:post_id])
create unique_index(:permalinks, [:uniform_resource_locator])
create table(:comments) do
add :text, :string, size: 100
add :lock_version, :integer, default: 1
add :post_id, references(:posts)
add :author_id, references(:users)
end
create table(:customs, primary_key: false) do
add :bid, :binary_id, primary_key: true
add :uuid, :uuid
end
create unique_index(:customs, [:uuid])
create table(:customs_customs, primary_key: false) do
add :custom_id1, references(:customs, column: :bid, type: :binary_id)
add :custom_id2, references(:customs, column: :bid, type: :binary_id)
end
create table(:barebones) do
add :num, :integer
end
create table(:transactions) do
add :num, :integer
end
create table(:lock_counters) do
add :count, :integer
end
create table(:orders) do
add :label, :string
add :item, :map
add :items, :map
add :meta, :map
add :permalink_id, references(:permalinks)
end
unless :array_type in ExUnit.configuration()[:exclude] do
create table(:tags) do
add :ints, {:array, :integer}
add :uuids, {:array, :uuid}, default: []
add :items, {:array, :map}
end
create table(:array_loggings) do
add :uuids, {:array, :uuid}, default: []
timestamps()
end
end
unless :bitstring_type in ExUnit.configuration()[:exclude] do
create table(:bitstrings) do
add :bs, :bitstring
add :bs_with_default, :bitstring, default: <<42::6>>
add :bs_with_size, :bitstring, size: 10
end
end
if Code.ensure_loaded?(Duration) do
unless :duration_type in ExUnit.configuration()[:exclude] do
create table(:durations) do
add :dur, :duration
add :dur_with_fields, :duration, fields: "MONTH"
add :dur_with_precision, :duration, precision: 4
add :dur_with_fields_and_precision, :duration, fields: "HOUR TO SECOND", precision: 1
add :dur_with_default, :duration, default: "10 MONTH"
end
end
end
create table(:composite_pk, primary_key: false) do
add :a, :integer, primary_key: true
add :b, :integer, primary_key: true
add :name, :string
end
create table(:corrupted_pk, primary_key: false) do
add :a, :string
end
create table(:posts_users_composite_pk) do
add :post_id, references(:posts), primary_key: true
add :user_id, references(:users), primary_key: true
timestamps()
end
create unique_index(:posts_users_composite_pk, [:post_id, :user_id])
create table(:usecs) do
add :naive_datetime_usec, :naive_datetime_usec
add :utc_datetime_usec, :utc_datetime_usec
end
create table(:bits) do
add :bit, :bit
end
create table(:loggings, primary_key: false) do
add :bid, :binary_id, primary_key: true
add :int, :integer
add :uuid, :uuid
timestamps()
end
end
end

View File

@@ -0,0 +1,23 @@
defmodule Ecto.Integration.Repo do
defmacro __using__(opts) do
quote do
use Ecto.Repo, unquote(opts)
@query_event __MODULE__
|> Module.split()
|> Enum.map(& &1 |> Macro.underscore() |> String.to_atom())
|> Kernel.++([:query])
def init(_, opts) do
fun = &Ecto.Integration.Repo.handle_event/4
:telemetry.attach_many(__MODULE__, [[:custom], @query_event], fun, :ok)
{:ok, opts}
end
end
end
def handle_event(event, latency, metadata, _config) do
handler = Process.delete(:telemetry) || fn _, _, _ -> :ok end
handler.(event, latency, metadata)
end
end

View File

@@ -0,0 +1,72 @@
defmodule Ecto.Adapter.Migration do
@moduledoc """
Specifies the adapter migrations API.
"""
alias Ecto.Migration.Constraint
alias Ecto.Migration.Table
alias Ecto.Migration.Index
alias Ecto.Migration.Reference
@type adapter_meta :: Ecto.Adapter.adapter_meta()
@type drop_mode :: :restrict | :cascade
@typedoc "All migration commands"
@type command ::
raw ::
String.t()
| {:create, Table.t(), [table_subcommand]}
| {:create_if_not_exists, Table.t(), [table_subcommand]}
| {:alter, Table.t(), [table_subcommand]}
| {:drop, Table.t(), drop_mode()}
| {:drop_if_exists, Table.t(), drop_mode()}
| {:create, Index.t()}
| {:create_if_not_exists, Index.t()}
| {:drop, Index.t(), drop_mode()}
| {:drop_if_exists, Index.t(), drop_mode()}
| {:create, Constraint.t()}
| {:drop, Constraint.t(), drop_mode()}
| {:drop_if_exists, Constraint.t(), drop_mode()}
@typedoc "All commands allowed within the block passed to `table/2`"
@type table_subcommand ::
{:add, field :: atom, type :: Ecto.Type.t() | Reference.t() | binary(), Keyword.t()}
| {:add_if_not_exists, field :: atom, type :: Ecto.Type.t() | Reference.t() | binary(),
Keyword.t()}
| {:modify, field :: atom, type :: Ecto.Type.t() | Reference.t() | binary(),
Keyword.t()}
| {:remove, field :: atom, type :: Ecto.Type.t() | Reference.t() | binary(),
Keyword.t()}
| {:remove, field :: atom}
| {:remove_if_exists, field :: atom, type :: Ecto.Type.t() | Reference.t() | binary()}
| {:remove_if_exists, field :: atom}
@typedoc """
A struct that represents a table or index in a database schema.
These database objects can be modified through the use of a Data
Definition Language, hence the name DDL object.
"""
@type ddl_object :: Table.t() | Index.t()
@doc """
Checks if the adapter supports ddl transaction.
"""
@callback supports_ddl_transaction? :: boolean
@doc """
Executes migration commands.
"""
@callback execute_ddl(adapter_meta, command, options :: Keyword.t()) ::
{:ok, [{Logger.level(), Logger.message(), Logger.metadata()}]}
@doc """
Locks the migrations table and emits the locked versions for callback execution.
It returns the result of calling the given function with a list of versions.
"""
@callback lock_for_migrations(adapter_meta, options :: Keyword.t(), fun) ::
result
when fun: (-> result), result: var
end

View File

@@ -0,0 +1,62 @@
defmodule Ecto.Adapter.Structure do
@moduledoc """
Specifies the adapter structure (dump/load) API.
"""
@doc """
Dumps the given structure.
The path will be looked in the `config` under :dump_path or
default to the structure path inside `default`.
Returns an `:ok` tuple if it was dumped successfully, an error tuple otherwise.
## Examples
structure_dump("priv/repo", username: "postgres",
database: "ecto_test",
hostname: "localhost")
"""
@callback structure_dump(default :: String.t(), config :: Keyword.t()) ::
{:ok, String.t()} | {:error, term}
@doc """
Loads the given structure.
The path will be looked in the `config` under :dump_path or
default to the structure path inside `default`.
Returns an `:ok` tuple if it was loaded successfully, an error tuple otherwise.
## Examples
structure_load("priv/repo", username: "postgres",
database: "ecto_test",
hostname: "localhost")
"""
@callback structure_load(default :: String.t(), config :: Keyword.t()) ::
{:ok, String.t()} | {:error, term}
@doc """
Runs the dump command for the given repo / config.
Calling this function will setup authentication and run the dump cli
command with your provided `args`.
The options in `opts` are passed directly to `System.cmd/3`.
Returns `{output, exit_status}` where `output` is a string of the stdout
(as long as no option `into` is provided, see `System.cmd/3`) and `exit_status`
is the exit status of the invocation. (`0` for success)
## Examples
iex> dump_cmd(["--data-only", "--table", "table_name"], [stdout_to_stderr: true], Acme.Repo.config())
{"--\n-- PostgreSQL database dump\n--\n" <> _rest, 0}
"""
@callback dump_cmd(args :: [String.t()], opts :: Keyword.t(), config :: Keyword.t()) ::
{output :: Collectable.t(), exit_status :: non_neg_integer()}
end

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,503 @@
defmodule Ecto.Migration.Runner do
@moduledoc false
use Agent, restart: :temporary
require Logger
alias Ecto.Migration.Table
alias Ecto.Migration.Index
alias Ecto.Migration.Constraint
alias Ecto.Migration.Command
@doc """
Runs the given migration.
"""
def run(repo, config, version, module, direction, operation, migrator_direction, opts) do
level = Keyword.get(opts, :log, :info)
sql = Keyword.get(opts, :log_migrations_sql, false)
log = %{level: level, sql: sql}
args = {self(), repo, config, module, direction, migrator_direction, log}
{:ok, runner} = DynamicSupervisor.start_child(Ecto.MigratorSupervisor, {__MODULE__, args})
metadata(runner, opts)
log(level, "== Running #{version} #{inspect(module)}.#{operation}/0 #{direction}")
{time, _} = :timer.tc(fn -> perform_operation(repo, module, operation) end)
log(level, "== Migrated #{version} in #{inspect(div(time, 100_000) / 10)}s")
after
stop()
end
@doc """
Stores the runner metadata.
"""
def metadata(runner, opts) do
prefix = opts[:prefix]
Process.put(:ecto_migration, %{runner: runner, prefix: prefix && to_string(prefix)})
end
@doc """
Starts the runner for the specified repo.
"""
def start_link({parent, repo, config, module, direction, migrator_direction, log}) do
Agent.start_link(fn ->
Process.link(parent)
%{
direction: direction,
repo: repo,
migration: module,
migrator_direction: migrator_direction,
command: nil,
subcommands: [],
log: log,
commands: [],
config: config
}
end)
end
@doc """
Stops the runner.
"""
def stop() do
Agent.stop(runner())
end
@doc """
Accesses the given repository configuration.
"""
def repo_config(key, default) do
Agent.get(runner(), &Keyword.get(&1.config, key, default))
end
@doc """
Returns the migrator command (up or down).
* forward + up: up
* forward + down: down
* forward + change: up
* backward + change: down
"""
def migrator_direction do
Agent.get(runner(), & &1.migrator_direction)
end
@doc """
Gets the repo for this migration
"""
def repo do
Agent.get(runner(), & &1.repo)
end
@doc """
Gets the prefix for this migration
"""
def prefix do
case Process.get(:ecto_migration) do
%{prefix: prefix} -> prefix
_ -> raise "could not find migration runner process for #{inspect(self())}"
end
end
@doc """
Executes queue migration commands.
Reverses the order commands are executed when doing a rollback
on a change/0 function and resets commands queue.
"""
def flush do
%{commands: commands, direction: direction, repo: repo, log: log, migration: migration} =
Agent.get_and_update(runner(), fn state -> {state, %{state | commands: []}} end)
commands = if direction == :backward, do: commands, else: Enum.reverse(commands)
for command <- commands do
execute_in_direction(repo, migration, direction, log, command)
end
end
@doc """
Queues command tuples or strings for execution.
Ecto.MigrationError will be raised when the server
is in `:backward` direction and `command` is irreversible.
"""
def execute(command) do
reply =
Agent.get_and_update(runner(), fn
%{command: nil} = state ->
{:ok, %{state | subcommands: [], commands: [command | state.commands]}}
%{command: _} = state ->
{:error, %{state | command: nil}}
end)
case reply do
:ok ->
:ok
:error ->
raise Ecto.MigrationError, "cannot execute nested commands"
end
end
@doc """
Starts a command.
"""
def start_command(command) do
reply =
Agent.get_and_update(runner(), fn
%{command: nil} = state ->
{:ok, %{state | command: command}}
%{command: _} = state ->
{:error, %{state | command: command}}
end)
case reply do
:ok ->
:ok
:error ->
raise Ecto.MigrationError, "cannot execute nested commands"
end
end
@doc """
Queues and clears current command. Must call `start_command/1` first.
"""
def end_command do
Agent.update(runner(), fn state ->
{operation, object} = state.command
command = {operation, object, Enum.reverse(state.subcommands)}
%{state | command: nil, subcommands: [], commands: [command | state.commands]}
end)
end
@doc """
Adds a subcommand to the current command. Must call `start_command/1` first.
"""
def subcommand(subcommand) do
reply =
Agent.get_and_update(runner(), fn
%{command: nil} = state ->
{:error, state}
state ->
{:ok, update_in(state.subcommands, &[subcommand | &1])}
end)
case reply do
:ok ->
:ok
:error ->
raise Ecto.MigrationError, message: "cannot execute command outside of block"
end
end
## Execute
defp execute_in_direction(repo, migration, :forward, log, %Command{up: up}) do
log_and_execute_ddl(repo, migration, log, up)
end
defp execute_in_direction(repo, migration, :forward, log, command) do
log_and_execute_ddl(repo, migration, log, command)
end
defp execute_in_direction(repo, migration, :backward, log, %Command{down: down}) do
log_and_execute_ddl(repo, migration, log, down)
end
defp execute_in_direction(repo, migration, :backward, log, command) do
if reversed = reverse(command) do
log_and_execute_ddl(repo, migration, log, reversed)
else
raise Ecto.MigrationError,
message:
"cannot reverse migration command: #{command(command)}. " <>
"You will need to explicitly define up/0 and down/0 in your migration"
end
end
defp reverse({:create, %Index{} = index}),
do: {:drop, index, :restrict}
defp reverse({:create_if_not_exists, %Index{} = index}),
do: {:drop_if_exists, index, :restrict}
defp reverse({:drop, %Index{} = index, _}),
do: {:create, index}
defp reverse({:drop_if_exists, %Index{} = index, _}),
do: {:create_if_not_exists, index}
defp reverse({:rename, %Index{} = index, new_name}),
do: {:rename, %{index | name: new_name}, index.name}
defp reverse({:create, %Table{} = table, _columns}),
do: {:drop, table, :restrict}
defp reverse({:create_if_not_exists, %Table{} = table, _columns}),
do: {:drop_if_exists, table, :restrict}
defp reverse({:rename, %Table{} = table_current, %Table{} = table_new}),
do: {:rename, table_new, table_current}
defp reverse({:rename, %Table{} = table, current_column, new_column}),
do: {:rename, table, new_column, current_column}
defp reverse({:alter, %Table{} = table, changes}) do
if reversed = table_reverse(changes, []) do
{:alter, table, reversed}
end
end
# It is not a good idea to reverse constraints because
# we can't guarantee data integrity when applying them back.
defp reverse({:create_if_not_exists, %Constraint{} = constraint}),
do: {:drop_if_exists, constraint, :restrict}
defp reverse({:create, %Constraint{} = constraint}),
do: {:drop, constraint, :restrict}
defp reverse(_command), do: false
defp table_reverse([{:remove, name, type, opts} | t], acc) do
table_reverse(t, [{:add, name, type, opts} | acc])
end
defp table_reverse([{:modify, name, type, opts} | t], acc) do
case opts[:from] do
nil ->
false
{reverse_type, from_opts} when is_list(from_opts) ->
reverse_from = {type, Keyword.delete(opts, :from)}
reverse_opts = Keyword.put(from_opts, :from, reverse_from)
table_reverse(t, [{:modify, name, reverse_type, reverse_opts} | acc])
reverse_type ->
reverse_opts = Keyword.put(opts, :from, type)
table_reverse(t, [{:modify, name, reverse_type, reverse_opts} | acc])
end
end
defp table_reverse([{:add, name, type, _opts} | t], acc) do
table_reverse(t, [{:remove, name, type, []} | acc])
end
defp table_reverse([_ | _], _acc) do
false
end
defp table_reverse([], acc) do
acc
end
## Helpers
defp perform_operation(repo, module, operation) do
if function_exported?(repo, :in_transaction?, 0) and repo.in_transaction?() do
if function_exported?(module, :after_begin, 0) do
module.after_begin()
flush()
end
apply(module, operation, [])
flush()
if function_exported?(module, :before_commit, 0) do
module.before_commit()
flush()
end
else
apply(module, operation, [])
flush()
end
end
defp runner do
case Process.get(:ecto_migration) do
%{runner: runner} -> runner
_ -> raise "could not find migration runner process for #{inspect(self())}"
end
end
defp log_and_execute_ddl(repo, migration, log, {instruction, %Index{} = index}) do
maybe_warn_index_ddl_transaction(index, migration)
maybe_warn_index_migration_lock(index, repo, migration)
log_and_execute_ddl(repo, log, {instruction, index})
end
defp log_and_execute_ddl(repo, _migration, log, command) do
log_and_execute_ddl(repo, log, command)
end
defp log_and_execute_ddl(_repo, _log, func) when is_function(func, 0) do
func.()
:ok
end
defp log_and_execute_ddl(repo, %{level: level, sql: sql}, command) do
log(level, command(command))
meta = Ecto.Adapter.lookup_meta(repo.get_dynamic_repo())
{:ok, logs} = repo.__adapter__().execute_ddl(meta, command, timeout: :infinity, log: sql)
Enum.each(logs, fn {ddl_log_level, message, metadata} ->
ddl_log(ddl_log_level, level, message, metadata)
end)
:ok
end
defp ddl_log(_level, false, _msg, _metadata), do: :ok
defp ddl_log(level, _, msg, metadata), do: log(level, msg, metadata)
defp log(level, msg, metadata \\ [])
defp log(false, _msg, _metadata), do: :ok
defp log(true, msg, metadata), do: Logger.log(:info, msg, metadata)
defp log(level, msg, metadata), do: Logger.log(level, msg, metadata)
defp maybe_warn_index_ddl_transaction(%{concurrently: true} = index, migration) do
migration_config = migration.__migration__()
if not migration_config[:disable_ddl_transaction] do
IO.warn(
"""
Migration #{inspect(migration)} has set index `#{index.name}` on table \
`#{index.table}` to concurrently but did not disable ddl transaction. \
Please set:
use Ecto.Migration
@disable_ddl_transaction true
""",
[]
)
end
end
defp maybe_warn_index_ddl_transaction(_index, _migration), do: :ok
defp maybe_warn_index_migration_lock(%{concurrently: true} = index, repo, migration) do
migration_lock_disabled = migration.__migration__()[:disable_migration_lock]
lock_strategy = repo.config()[:migration_lock]
adapter = repo.__adapter__()
case {migration_lock_disabled, adapter, lock_strategy} do
{false, Ecto.Adapters.Postgres, :pg_advisory_lock} ->
:ok
{false, Ecto.Adapters.Postgres, _} ->
IO.warn(
"""
Migration #{inspect(migration)} has set index `#{index.name}` on table \
`#{index.table}` to concurrently but did not disable migration lock. \
Please set:
use Ecto.Migration
@disable_migration_lock true
Alternatively, consider using advisory locks during migrations in the \
repo configuration:
config #{inspect(repo)}, migration_lock: :pg_advisory_lock
""",
[]
)
{false, _adapter, _migration_lock} ->
IO.warn(
"""
Migration #{inspect(migration)} has set index `#{index.name}` on table \
`#{index.table}` to concurrently but did not disable migration lock. \
Please set:
use Ecto.Migration
@disable_migration_lock true
""",
[]
)
_ ->
:ok
end
end
defp maybe_warn_index_migration_lock(_index, _repo, _migration), do: :ok
defp command(ddl) when is_binary(ddl) or is_list(ddl),
do: "execute #{inspect(ddl)}"
defp command({:create, %Table{} = table, _}),
do: "create table #{quote_name(table.prefix, table.name)}"
defp command({:create_if_not_exists, %Table{} = table, _}),
do: "create table if not exists #{quote_name(table.prefix, table.name)}"
defp command({:alter, %Table{} = table, _}),
do: "alter table #{quote_name(table.prefix, table.name)}"
defp command({:drop, %Table{} = table, mode}),
do: "drop table #{quote_name(table.prefix, table.name)}#{drop_mode(mode)}"
defp command({:drop_if_exists, %Table{} = table, mode}),
do: "drop table if exists #{quote_name(table.prefix, table.name)}#{drop_mode(mode)}"
defp command({:create, %Index{} = index}),
do: "create index #{quote_name(index.prefix, index.name)}"
defp command({:create_if_not_exists, %Index{} = index}),
do: "create index if not exists #{quote_name(index.prefix, index.name)}"
defp command({:drop, %Index{} = index, mode}),
do: "drop index #{quote_name(index.prefix, index.name)}#{drop_mode(mode)}"
defp command({:drop_if_exists, %Index{} = index, mode}),
do: "drop index if exists #{quote_name(index.prefix, index.name)}#{drop_mode(mode)}"
defp command({:rename, %Index{} = index_current, new_name}),
do: "rename index #{quote_name(index_current.prefix, index_current.name)} to #{new_name}"
defp command({:rename, %Table{} = current_table, %Table{} = new_table}),
do:
"rename table #{quote_name(current_table.prefix, current_table.name)} to #{quote_name(new_table.prefix, new_table.name)}"
defp command({:rename, %Table{} = table, current_column, new_column}),
do:
"rename column #{current_column} to #{new_column} on table #{quote_name(table.prefix, table.name)}"
defp command({:create, %Constraint{check: nil, exclude: nil}}),
do: raise(ArgumentError, "a constraint must have either a check or exclude option")
defp command({:create, %Constraint{check: check, exclude: exclude}})
when is_binary(check) and is_binary(exclude),
do: raise(ArgumentError, "a constraint must not have both check and exclude options")
defp command({:create, %Constraint{check: check} = constraint}) when is_binary(check),
do:
"create check constraint #{constraint.name} on table #{quote_name(constraint.prefix, constraint.table)}"
defp command({:create, %Constraint{exclude: exclude} = constraint}) when is_binary(exclude),
do:
"create exclude constraint #{constraint.name} on table #{quote_name(constraint.prefix, constraint.table)}"
defp command({:drop, %Constraint{} = constraint, _}),
do:
"drop constraint #{constraint.name} from table #{quote_name(constraint.prefix, constraint.table)}"
defp command({:drop_if_exists, %Constraint{} = constraint, _}),
do:
"drop constraint if exists #{constraint.name} from table #{quote_name(constraint.prefix, constraint.table)}"
defp drop_mode(:restrict), do: ""
defp drop_mode(:cascade), do: " cascade"
defp quote_name(nil, name), do: quote_name(name)
defp quote_name(prefix, name), do: quote_name(prefix) <> "." <> quote_name(name)
defp quote_name(name) when is_atom(name), do: quote_name(Atom.to_string(name))
defp quote_name(name), do: name
end

View File

@@ -0,0 +1,81 @@
defmodule Ecto.Migration.SchemaMigration do
# Defines a schema that works with a table that tracks schema migrations.
# The table name defaults to `schema_migrations`.
@moduledoc false
use Ecto.Schema
import Ecto.Query, only: [from: 2]
@primary_key false
schema "schema_migrations" do
field :version, :integer, primary_key: true
timestamps updated_at: false
end
# The migration flag is used to signal to the repository
# we are in a migration operation.
@default_opts [
timeout: :infinity,
log: false,
# Keep schema_migration for backwards compatibility
schema_migration: true,
ecto_query: :schema_migration,
telemetry_options: [schema_migration: true]
]
def ensure_schema_migrations_table!(repo, config, opts) do
{repo, source} = get_repo_and_source(repo, config)
table_name = String.to_atom(source)
table = %Ecto.Migration.Table{name: table_name, prefix: opts[:prefix]}
meta = Ecto.Adapter.lookup_meta(repo.get_dynamic_repo())
commands = [
{:add, :version, :bigint, primary_key: true},
{:add, :inserted_at, :naive_datetime, []}
]
repo.__adapter__().execute_ddl(meta, {:create_if_not_exists, table, commands}, @default_opts)
end
def versions(repo, config, prefix) do
{repo, source} = get_repo_and_source(repo, config)
from_opts = [prefix: prefix] ++ @default_opts
query =
if Keyword.get(config, :migration_cast_version_column, false) do
from(m in source, select: type(m.version, :integer))
else
from(m in source, select: m.version)
end
{repo, query, from_opts}
end
def up(repo, config, version, opts) do
{repo, source} = get_repo_and_source(repo, config)
%__MODULE__{version: version}
|> Ecto.put_meta(source: source)
|> repo.insert(default_opts(opts))
end
def down(repo, config, version, opts) do
{repo, source} = get_repo_and_source(repo, config)
from(m in source, where: m.version == type(^version, :integer))
|> repo.delete_all(default_opts(opts))
end
def get_repo_and_source(repo, config) do
{Keyword.get(config, :migration_repo, repo),
Keyword.get(config, :migration_source, "schema_migrations")}
end
defp default_opts(opts) do
Keyword.merge(
@default_opts,
prefix: opts[:prefix],
log: Keyword.get(opts, :log_migrator_sql, false)
)
end
end

View File

@@ -0,0 +1,861 @@
defmodule Ecto.Migrator do
@moduledoc """
Lower level API for managing migrations.
EctoSQL provides three mix tasks for running and managing migrations:
* `mix ecto.migrate` - migrates a repository
* `mix ecto.rollback` - rolls back a particular migration
* `mix ecto.migrations` - shows all migrations and their status
Those tasks are built on top of the functions in this module.
While the tasks above cover most use cases, it may be necessary
from time to time to jump into the lower level API. For example,
if you are assembling an Elixir release, Mix is not available,
so this module provides a nice complement to still migrate your
system.
To learn more about migrations in general, see `Ecto.Migration`.
## Example: Running an individual migration
Imagine you have this migration:
defmodule MyApp.MigrationExample do
use Ecto.Migration
def up do
execute "CREATE TABLE users(id serial PRIMARY_KEY, username text)"
end
def down do
execute "DROP TABLE users"
end
end
You can execute it manually with:
Ecto.Migrator.up(Repo, 20080906120000, MyApp.MigrationExample)
## Example: Running migrations in a release
Elixir v1.9 introduces `mix release`, which generates a self-contained
directory that consists of your application code, all of its dependencies,
plus the whole Erlang Virtual Machine (VM) and runtime.
When a release is assembled, Mix is no longer available inside a release
and therefore none of the Mix tasks. Users may still need a mechanism to
migrate their databases. This can be achieved with using the `Ecto.Migrator`
module:
defmodule MyApp.Release do
@app :my_app
def migrate do
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.load(@app)
Application.fetch_env!(@app, :ecto_repos)
end
end
The example above uses `with_repo/3` to make sure the repository is
started and then runs all migrations up or a given migration down.
Note you will have to replace `MyApp` and `:my_app` on the first two
lines by your actual application name. Once the file above is added
to your application, you can assemble a new release and invoke the
commands above in the release root like this:
$ bin/my_app eval "MyApp.Release.migrate"
$ bin/my_app eval "MyApp.Release.rollback(MyApp.Repo, 20190417140000)"
## Example: Running migrations on application startup
Add the following to the top of your application children spec:
{Ecto.Migrator,
repos: Application.fetch_env!(:my_app, :ecto_repos),
skip: System.get_env("SKIP_MIGRATIONS") == "true"}
To skip migrations you can also pass `skip: true` or as in the example
set the environment variable `SKIP_MIGRATIONS` to a truthy value.
And all other options described in `up/4` are allowed,
for example if you want to log the SQL commands,
and run migrations in a prefix:
{Ecto.Migrator,
repos: Application.fetch_env!(:my_app, :ecto_repos),
log_migrator_sql: true,
prefix: "my_app"}
To roll back you'd do it normally:
$ mix ecto.rollback
"""
require Logger
require Ecto.Query
alias Ecto.Migration.Runner
alias Ecto.Migration.SchemaMigration
@doc """
Ensures the repo is started to perform migration operations.
All of the application required to run the repo will be started
before hand with chosen mode. If the repo has not yet been started,
it is manually started, with a `:pool_size` of 2, before the given
function is executed, and the repo is then terminated. If the repo
was already started, then the function is directly executed, without
terminating the repo afterwards.
Although this function was designed to start repositories for running
migrations, it can be used by any code, Mix task, or release tooling
that needs to briefly start a repository to perform a certain operation
and then terminate.
The repo may also configure a `:start_apps_before_migration` option
which is a list of applications to be started before the migration
runs.
It returns `{:ok, fun_return, apps}`, with all apps that have been
started, or `{:error, term}`.
## Options
* `:pool_size` - The pool size to start the repo for migrations.
Defaults to 2.
* `:mode` - The mode to start all applications.
Defaults to `:permanent`.
## Examples
{:ok, _, _} =
Ecto.Migrator.with_repo(repo, fn repo ->
Ecto.Migrator.run(repo, :up, all: true)
end)
"""
def with_repo(repo, fun, opts \\ []) do
config = repo.config()
mode = Keyword.get(opts, :mode, :permanent)
apps = [:ecto_sql | config[:start_apps_before_migration] || []]
extra_started =
Enum.flat_map(apps, fn app ->
{:ok, started} = Application.ensure_all_started(app, mode)
started
end)
{:ok, repo_started} = repo.__adapter__().ensure_all_started(config, mode)
started = extra_started ++ repo_started
pool_size = Keyword.get(opts, :pool_size, 2)
migration_repo = config[:migration_repo] || repo
case ensure_repo_started(repo, pool_size) do
{:ok, repo_after} ->
case ensure_migration_repo_started(migration_repo, repo) do
{:ok, migration_repo_after} ->
try do
{:ok, fun.(repo), started}
after
after_action(repo, repo_after)
after_action(migration_repo, migration_repo_after)
end
{:error, _} = error ->
after_action(repo, repo_after)
error
end
{:error, _} = error ->
error
end
end
@doc """
Gets the migrations path from a repository.
This function accepts an optional second parameter to customize the
migrations directory. This can be used to specify a custom migrations
path.
"""
@spec migrations_path(Ecto.Repo.t(), String.t()) :: String.t()
def migrations_path(repo, directory \\ "migrations") do
config = repo.config()
priv = config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}"
app = Keyword.fetch!(config, :otp_app)
Application.app_dir(app, Path.join(priv, directory))
end
@doc """
Gets all migrated versions.
This function ensures the migration table exists
if no table has been defined yet.
## Options
* `:prefix` - the prefix to run the migrations on
* `:dynamic_repo` - the name of the Repo supervisor process.
See `c:Ecto.Repo.put_dynamic_repo/1`.
* `:skip_table_creation` - skips any attempt to create the migration table
Useful for situations where user needs to check migrations but has
insufficient permissions to create the table. Note that migrations
commands may fail if this is set to true. Defaults to `false`. Accepts a
boolean.
"""
@spec migrated_versions(Ecto.Repo.t(), Keyword.t()) :: [integer]
def migrated_versions(repo, opts \\ []) do
lock_for_migrations(true, repo, opts, fn _config, versions -> versions end)
end
@doc """
Runs an up migration on the given repository.
## Options
* `:log` - the level to use for logging of migration instructions.
Defaults to `:info`. Can be any of `Logger.level/0` values or a boolean.
If `false`, it also avoids logging messages from the database.
* `:log_migrations_sql` - the level to use for logging of SQL commands
generated by migrations. Can be any of the `Logger.level/0` values
or a boolean. If `false`, logging is disabled. If `true`, uses the configured
Repo logger level. Defaults to `false`
* `:log_migrator_sql` - the level to use for logging of SQL commands emitted
by the migrator, such as transactions, locks, etc. Can be any of the `Logger.level/0`
values or a boolean. If `false`, logging is disabled. If `true`, uses the configured
Repo logger level. Defaults to `false`
* `:prefix` - the prefix to run the migrations on
* `:dynamic_repo` - the name of the Repo supervisor process.
See `c:Ecto.Repo.put_dynamic_repo/1`.
* `:strict_version_order` - abort when applying a migration with old timestamp
(otherwise it emits a warning)
"""
@spec up(Ecto.Repo.t(), integer, module, Keyword.t()) :: :ok | :already_up
def up(repo, version, module, opts \\ []) do
conditional_lock_for_migrations(module, version, repo, opts, fn config, versions ->
if version in versions do
:already_up
else
result = do_up(repo, config, version, module, opts)
if version != Enum.max([version | versions]) do
latest = Enum.max(versions)
message = """
You are running migration #{version} but an older \
migration with version #{latest} has already run.
This can be an issue if you have already ran #{latest} in production \
because a new deployment may migrate #{version} but a rollback command \
would revert #{latest} instead of #{version}.
If this can be an issue, we recommend to rollback #{version} and change \
it to a version later than #{latest}.
"""
if opts[:strict_version_order] do
raise Ecto.MigrationError, message
else
Logger.warning(message)
end
end
result
end
end)
end
defp do_up(repo, config, version, module, opts) do
async_migrate_maybe_in_transaction(repo, config, version, module, :up, opts, fn ->
attempt(repo, config, version, module, :forward, :up, :up, opts) ||
attempt(repo, config, version, module, :forward, :change, :up, opts) ||
{:error,
Ecto.MigrationError.exception(
"#{inspect(module)} does not implement a `up/0` or `change/0` function"
)}
end)
end
@doc """
Runs a down migration on the given repository.
## Options
* `:log` - the level to use for logging of migration commands. Defaults to `:info`.
Can be any of `Logger.level/0` values or a boolean.
* `:log_migrations_sql` - the level to use for logging of SQL commands
generated by migrations. Can be any of the `Logger.level/0` values
or a boolean. If `false`, logging is disabled. If `true`, uses the configured
Repo logger level. Defaults to `false`
* `:log_migrator_sql` - the level to use for logging of SQL commands emitted
by the migrator, such as transactions, locks, etc. Can be any of the `Logger.level/0`
values or a boolean. If `false`, logging is disabled. If `true`, uses the configured
Repo logger level. Defaults to `false`
* `:prefix` - the prefix to run the migrations on
* `:dynamic_repo` - the name of the Repo supervisor process.
See `c:Ecto.Repo.put_dynamic_repo/1`.
"""
@spec down(Ecto.Repo.t(), integer, module) :: :ok | :already_down
def down(repo, version, module, opts \\ []) do
conditional_lock_for_migrations(module, version, repo, opts, fn config, versions ->
if version in versions do
do_down(repo, config, version, module, opts)
else
:already_down
end
end)
end
defp do_down(repo, config, version, module, opts) do
async_migrate_maybe_in_transaction(repo, config, version, module, :down, opts, fn ->
attempt(repo, config, version, module, :forward, :down, :down, opts) ||
attempt(repo, config, version, module, :backward, :change, :down, opts) ||
{:error,
Ecto.MigrationError.exception(
"#{inspect(module)} does not implement a `down/0` or `change/0` function"
)}
end)
end
defp async_migrate_maybe_in_transaction(repo, config, version, module, direction, opts, fun) do
dynamic_repo = repo.get_dynamic_repo()
fun_with_status = fn ->
result = fun.()
apply(SchemaMigration, direction, [repo, config, version, opts])
result
end
fn -> run_maybe_in_transaction(repo, dynamic_repo, module, fun_with_status, opts) end
|> Task.async()
|> Task.await(:infinity)
end
defp run_maybe_in_transaction(repo, dynamic_repo, module, fun, opts) do
repo.put_dynamic_repo(dynamic_repo)
if module.__migration__()[:disable_ddl_transaction] ||
not repo.__adapter__().supports_ddl_transaction?() do
fun.()
else
{:ok, result} = repo.transaction(fun, log: migrator_log(opts), timeout: :infinity)
result
end
catch
kind, reason ->
{kind, reason, __STACKTRACE__}
end
defp attempt(repo, config, version, module, direction, operation, reference, opts) do
if Code.ensure_loaded?(module) and function_exported?(module, operation, 0) do
Runner.run(repo, config, version, module, direction, operation, reference, opts)
:ok
end
end
@doc """
Runs migrations for the given repository.
Equivalent to:
Ecto.Migrator.run(repo, [Ecto.Migrator.migrations_path(repo)], direction, opts)
See `run/4` for more information.
"""
@spec run(Ecto.Repo.t(), atom, Keyword.t()) :: [integer]
def run(repo, direction, opts) do
run(repo, [migrations_path(repo)], direction, opts)
end
@doc ~S"""
Apply migrations to a repository with a given strategy.
The second argument identifies where the migrations are sourced from.
A binary representing directory (or a list of binaries representing
directories) may be passed, in which case we will load all files
following the "#{VERSION}_#{NAME}.exs" schema. The `migration_source`
may also be a list of tuples that identify the version number and
migration modules to be run, for example:
Ecto.Migrator.run(Repo, [{0, MyApp.Migration1}, {1, MyApp.Migration2}, ...], :up, opts)
A strategy (which is one of `:all`, `:step`, `:to`, or `:to_exclusive`) must be given as
an option.
## Execution model
In order to run migrations, at least two database connections are
necessary. One is used to lock the "schema_migrations" table and
the other one to effectively run the migrations. This allows multiple
nodes to run migrations at the same time, but guarantee that only one
of them will effectively migrate the database.
A downside of this approach is that migrations cannot run dynamically
during test under the `Ecto.Adapters.SQL.Sandbox`, as the sandbox has
to share a single connection across processes to guarantee the changes
can be reverted.
## Options
* `:all` - runs all available if `true`
* `:step` - runs the specific number of migrations
* `:to` - runs all until the supplied version is reached
(including the version given in `:to`)
* `:to_exclusive` - runs all until the supplied version is reached
(excluding the version given in `:to_exclusive`)
Plus all other options described in `up/4`.
"""
@spec run(Ecto.Repo.t(), String.t() | [String.t()] | [{integer, module}], atom, Keyword.t()) ::
[integer]
def run(repo, migration_source, direction, opts) do
migration_source = List.wrap(migration_source)
pending =
lock_for_migrations(true, repo, opts, fn _config, versions ->
cond do
opts[:all] ->
pending_all(versions, migration_source, direction)
to = opts[:to] ->
pending_to(versions, migration_source, direction, to)
to_exclusive = opts[:to_exclusive] ->
pending_to_exclusive(versions, migration_source, direction, to_exclusive)
step = opts[:step] ->
pending_step(versions, migration_source, direction, step)
true ->
{:error,
ArgumentError.exception(
"expected one of :all, :to, :to_exclusive, or :step strategies"
)}
end
end)
# The lock above already created the table, so we can now skip it.
opts = Keyword.put(opts, :skip_table_creation, true)
ensure_no_duplication!(pending)
migrate(Enum.map(pending, &load_migration!/1), direction, repo, opts)
end
@doc """
Returns an array of tuples as the migration status of the given repo,
without actually running any migrations.
Equivalent to:
Ecto.Migrator.migrations(repo, [Ecto.Migrator.migrations_path(repo)])
"""
@spec migrations(Ecto.Repo.t()) :: [{:up | :down, id :: integer(), name :: String.t()}]
def migrations(repo) do
migrations(repo, [migrations_path(repo)])
end
@doc """
Returns an array of tuples as the migration status of the given repo,
without actually running any migrations.
"""
@spec migrations(Ecto.Repo.t(), String.t() | [String.t()], Keyword.t()) ::
[{:up | :down, id :: integer(), name :: String.t()}]
def migrations(repo, directories, opts \\ []) do
directories = List.wrap(directories)
repo
|> migrated_versions(opts)
|> collect_migrations(directories)
|> Enum.sort_by(fn {_, version, _} -> version end)
end
use GenServer
@doc """
Runs migrations as part of your supervision tree.
## Options
* `:repos` - Required option to tell the migrator which Repo's to
migrate. Example: `repos: [MyApp.Repo]`
* `:skip` - Option to skip migrations. Defaults to `false`.
Plus all other options described in `up/4`.
See "Example: Running migrations on application startup" for more info.
"""
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(opts) do
{repos, opts} = Keyword.pop!(opts, :repos)
{skip?, opts} = Keyword.pop(opts, :skip, false)
{migrator, opts} = Keyword.pop(opts, :migrator, &Ecto.Migrator.run/3)
opts = Keyword.put(opts, :all, true)
unless skip? do
for repo <- repos do
{:ok, _, _} = with_repo(repo, &migrator.(&1, :up, opts))
end
end
:ignore
end
defp collect_migrations(versions, migration_source) do
ups_with_file =
versions
|> pending_in_direction(migration_source, :down)
|> Enum.map(fn {version, name, _} -> {:up, version, name} end)
ups_without_file =
versions
|> versions_without_file(migration_source)
|> Enum.map(fn version -> {:up, version, "** FILE NOT FOUND **"} end)
downs =
versions
|> pending_in_direction(migration_source, :up)
|> Enum.map(fn {version, name, _} -> {:down, version, name} end)
ups_with_file ++ ups_without_file ++ downs
end
defp versions_without_file(versions, migration_source) do
versions_with_file =
migration_source
|> migrations_for()
|> Enum.map(fn {version, _, _} -> version end)
versions -- versions_with_file
end
defp lock_for_migrations(lock_or_migration_number, repo, opts, fun) do
dynamic_repo = Keyword.get(opts, :dynamic_repo, repo.get_dynamic_repo())
skip_table_creation = Keyword.get(opts, :skip_table_creation, false)
previous_dynamic_repo = repo.put_dynamic_repo(dynamic_repo)
try do
config = repo.config()
unless skip_table_creation do
verbose_schema_migration(repo, "create schema migrations table", fn ->
SchemaMigration.ensure_schema_migrations_table!(repo, config, opts)
end)
end
{migration_repo, query, all_opts} = SchemaMigration.versions(repo, config, opts[:prefix])
migration_lock? =
Keyword.get(opts, :migration_lock, Keyword.get(config, :migration_lock, true))
opts =
opts
|> Keyword.put(:migration_source, config[:migration_source] || "schema_migrations")
|> Keyword.put(:log, migrator_log(opts))
result =
if lock_or_migration_number && migration_lock? do
# If there is a migration_repo, it wins over dynamic_repo,
# otherwise the dynamic_repo is the one locked in migrations.
meta_repo = if migration_repo != repo, do: migration_repo, else: dynamic_repo
meta = Ecto.Adapter.lookup_meta(meta_repo)
migration_repo.__adapter__().lock_for_migrations(meta, opts, fn ->
fun.(config, migration_repo.all(query, all_opts))
end)
else
fun.(config, migration_repo.all(query, all_opts))
end
case result do
{kind, reason, stacktrace} ->
:erlang.raise(kind, reason, stacktrace)
{:error, error} ->
raise error
result ->
result
end
after
repo.put_dynamic_repo(previous_dynamic_repo)
end
end
defp conditional_lock_for_migrations(module, version, repo, opts, fun) do
lock = if module.__migration__()[:disable_migration_lock], do: false, else: version
lock_for_migrations(lock, repo, opts, fun)
end
defp pending_to(versions, migration_source, direction, target) when is_integer(target) do
within_target_version? = fn
{version, _, _}, target, :up ->
version <= target
{version, _, _}, target, :down ->
version >= target
end
pending_in_direction(versions, migration_source, direction)
|> Enum.take_while(&within_target_version?.(&1, target, direction))
end
defp pending_to_exclusive(versions, migration_source, direction, target)
when is_integer(target) do
within_target_version? = fn
{version, _, _}, target, :up ->
version < target
{version, _, _}, target, :down ->
version > target
end
pending_in_direction(versions, migration_source, direction)
|> Enum.take_while(&within_target_version?.(&1, target, direction))
end
defp pending_step(versions, migration_source, direction, count) do
pending_in_direction(versions, migration_source, direction)
|> Enum.take(count)
end
defp pending_all(versions, migration_source, direction) do
pending_in_direction(versions, migration_source, direction)
end
defp pending_in_direction(versions, migration_source, :up) do
migration_source
|> migrations_for()
|> Enum.filter(fn {version, _name, _file} -> version not in versions end)
end
defp pending_in_direction(versions, migration_source, :down) do
migration_source
|> migrations_for()
|> Enum.filter(fn {version, _name, _file} -> version in versions end)
|> Enum.reverse()
end
defp migrations_for(migration_source) when is_list(migration_source) do
migration_source
|> Enum.flat_map(fn
directory when is_binary(directory) ->
Path.join([directory, "**", "*.{ex,exs}"])
|> Path.wildcard()
|> Enum.map(&extract_migration_info/1)
|> Enum.filter(& &1)
{version, module} ->
[{version, module, module}]
end)
|> Enum.sort()
end
defp extract_migration_info(file) do
base = Path.basename(file)
case Integer.parse(Path.rootname(base)) do
{integer, "_" <> name} ->
if Path.extname(base) == ".ex" do
# See: https://github.com/elixir-ecto/ecto_sql/issues/599
IO.warn(
"""
file looks like a migration but ends in .ex. \
Migration files should end in .exs. Use "mix ecto.gen.migration" to generate \
migration files with the correct extension.\
""",
stacktrace_info(file: file)
)
nil
else
{integer, name, file}
end
_ ->
nil
end
end
# TODO: Remove when we require Elixir 1.14
if Version.match?(System.version(), ">= 1.14.0") do
defp stacktrace_info(info), do: info
else
defp stacktrace_info(_info), do: []
end
defp ensure_no_duplication!([{version, name, _} | t]) do
cond do
List.keyfind(t, version, 0) ->
raise Ecto.MigrationError,
"migrations can't be executed, migration version #{version} is duplicated"
List.keyfind(t, name, 1) ->
raise Ecto.MigrationError,
"migrations can't be executed, migration name #{name} is duplicated"
true ->
ensure_no_duplication!(t)
end
end
defp ensure_no_duplication!([]), do: :ok
defp load_migration!({version, _, mod}) when is_atom(mod) do
if migration?(mod) do
{version, mod}
else
raise Ecto.MigrationError, "module #{inspect(mod)} is not an Ecto.Migration"
end
end
defp load_migration!({version, _, file}) when is_binary(file) do
loaded_modules = file |> Code.compile_file() |> Enum.map(&elem(&1, 0))
if mod = Enum.find(loaded_modules, &migration?/1) do
{version, mod}
else
raise Ecto.MigrationError,
"file #{Path.relative_to_cwd(file)} does not define an Ecto.Migration"
end
end
defp migration?(mod) do
Code.ensure_loaded?(mod) and function_exported?(mod, :__migration__, 0)
end
defp migrate([], direction, _repo, opts) do
level = Keyword.get(opts, :log, :info)
log(level, "Migrations already #{direction}")
[]
end
defp migrate(migrations, direction, repo, opts) do
for {version, mod} <- migrations,
do_direction(direction, repo, version, mod, opts),
do: version
end
defp do_direction(:up, repo, version, mod, opts) do
conditional_lock_for_migrations(mod, version, repo, opts, fn config, versions ->
unless version in versions do
do_up(repo, config, version, mod, opts)
end
end)
end
defp do_direction(:down, repo, version, mod, opts) do
conditional_lock_for_migrations(mod, version, repo, opts, fn config, versions ->
if version in versions do
do_down(repo, config, version, mod, opts)
end
end)
end
defp verbose_schema_migration(repo, reason, fun) do
try do
fun.()
rescue
error ->
Logger.error("""
Could not #{reason}. This error usually happens due to the following:
* The database does not exist
* The "schema_migrations" table, which Ecto uses for managing
migrations, was defined by another library
* There is a deadlock while migrating (such as using concurrent
indexes with a migration_lock)
To fix the first issue, run "mix ecto.create" for the desired MIX_ENV.
To address the second, you can run "mix ecto.drop" followed by
"mix ecto.create", both for the desired MIX_ENV. Alternatively you may
configure Ecto to use another table and/or repository for managing
migrations:
config #{inspect(repo.config()[:otp_app])}, #{inspect(repo)},
migration_source: "some_other_table_for_schema_migrations",
migration_repo: AnotherRepoForSchemaMigrations
The full error report is shown below.
""")
reraise error, __STACKTRACE__
end
end
defp log(false, _msg), do: :ok
defp log(true, msg), do: Logger.info(msg)
defp log(level, msg), do: Logger.log(level, msg)
defp migrator_log(opts) do
Keyword.get(opts, :log_migrator_sql, false)
end
defp ensure_repo_started(repo, pool_size) do
case repo.start_link(pool_size: pool_size) do
{:ok, _} ->
{:ok, :stop}
{:error, {:already_started, _pid}} ->
{:ok, :restart}
{:error, _} = error ->
error
end
end
defp ensure_migration_repo_started(repo, repo) do
{:ok, :noop}
end
defp ensure_migration_repo_started(migration_repo, _repo) do
case migration_repo.start_link() do
{:ok, _} ->
{:ok, :stop}
{:error, {:already_started, _pid}} ->
{:ok, :noop}
{:error, _} = error ->
error
end
end
defp after_action(repo, :restart) do
if Process.whereis(repo) do
%{pid: pid} = Ecto.Adapter.lookup_meta(repo)
Supervisor.restart_child(repo, pid)
end
end
defp after_action(repo, :stop) do
repo.stop()
end
defp after_action(_repo, :noop) do
:noop
end
end

View File

@@ -0,0 +1,45 @@
defmodule Mix.EctoSQL do
@moduledoc false
@doc """
Ensures the given repository's migrations paths exists on the file system.
"""
@spec ensure_migrations_paths(Ecto.Repo.t(), Keyword.t()) :: [String.t()]
def ensure_migrations_paths(repo, opts) do
paths = Keyword.get_values(opts, :migrations_path)
paths = if paths == [], do: [Path.join(source_repo_priv(repo), "migrations")], else: paths
if not Mix.Project.umbrella?() do
for path <- paths, not File.dir?(path) do
raise_missing_migrations(Path.relative_to_cwd(path), repo)
end
end
paths
end
defp raise_missing_migrations(path, repo) do
Mix.raise("""
Could not find migrations directory #{inspect(path)}
for repo #{inspect(repo)}.
This may be because you are in a new project and the
migration directory has not been created yet. Creating an
empty directory at the path above will fix this error.
If you expected existing migrations to be found, please
make sure your repository has been properly configured
and the configured path exists.
""")
end
@doc """
Returns the private repository path relative to the source.
"""
def source_repo_priv(repo) do
config = repo.config()
priv = config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}"
app = Keyword.fetch!(config, :otp_app)
Path.join(Mix.Project.deps_paths()[app] || File.cwd!(), priv)
end
end

View File

@@ -0,0 +1,115 @@
defmodule Mix.Tasks.Ecto.Dump do
use Mix.Task
import Mix.Ecto
import Mix.EctoSQL
@shortdoc "Dumps the repository database structure"
@default_opts [quiet: false]
@aliases [
d: :dump_path,
q: :quiet,
r: :repo
]
@switches [
dump_path: :string,
quiet: :boolean,
repo: [:string, :keep],
no_compile: :boolean,
no_deps_check: :boolean,
prefix: [:string, :keep]
]
@moduledoc """
Dumps the current environment's database structure for the
given repository into a structure file.
The repository must be set under `:ecto_repos` in the
current app configuration or given via the `-r` option.
This task needs some shell utility to be present on the machine
running the task.
Database | Utility needed
:--------- | :-------------
PostgreSQL | pg_dump
MySQL | mysqldump
## Example
$ mix ecto.dump
## Command line options
* `-r`, `--repo` - the repo to load the structure info from
* `-d`, `--dump-path` - the path of the dump file to create
* `-q`, `--quiet` - run the command quietly
* `--no-compile` - does not compile applications before dumping
* `--no-deps-check` - does not check dependencies before dumping
* `--prefix` - prefix that will be included in the structure dump.
Can include multiple prefixes (ex. `--prefix foo --prefix bar`) with
PostgreSQL but not MySQL. When specified, the prefixes will have
their definitions dumped along with the data in their migration table.
The default behavior is dependent on the adapter for backwards compatibility
reasons. For PostgreSQL, 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. For MySQL, only the configured
database and its migration table are dumped.
"""
@impl true
def run(args) do
{opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
dump_prefixes =
case Keyword.get_values(opts, :prefix) do
[_ | _] = prefixes -> prefixes
[] -> nil
end
opts =
@default_opts
|> Keyword.merge(opts)
|> Keyword.put(:dump_prefixes, dump_prefixes)
Enum.each(parse_repo(args), fn repo ->
ensure_repo(repo, args)
ensure_implements(
repo.__adapter__(),
Ecto.Adapter.Structure,
"dump structure for #{inspect(repo)}"
)
migration_repo = repo.config()[:migration_repo] || repo
for repo <- Enum.uniq([repo, migration_repo]) do
config = Keyword.merge(repo.config(), opts)
start_time = System.system_time()
case repo.__adapter__().structure_dump(source_repo_priv(repo), config) do
{:ok, location} ->
unless opts[:quiet] do
elapsed =
System.convert_time_unit(System.system_time() - start_time, :native, :microsecond)
Mix.shell().info(
"The structure for #{inspect(repo)} has been dumped to #{location} in #{format_time(elapsed)}"
)
end
{:error, term} when is_binary(term) ->
Mix.raise("The structure for #{inspect(repo)} couldn't be dumped: #{term}")
{:error, term} ->
Mix.raise("The structure for #{inspect(repo)} couldn't be dumped: #{inspect(term)}")
end
end
end)
end
defp format_time(microsec) when microsec < 1_000, do: "#{microsec} μs"
defp format_time(microsec) when microsec < 1_000_000, do: "#{div(microsec, 1_000)} ms"
defp format_time(microsec), do: "#{Float.round(microsec / 1_000_000.0)} s"
end

View File

@@ -0,0 +1,130 @@
defmodule Mix.Tasks.Ecto.Gen.Migration do
use Mix.Task
import Macro, only: [camelize: 1, underscore: 1]
import Mix.Generator
import Mix.Ecto
import Mix.EctoSQL
@shortdoc "Generates a new migration for the repo"
@aliases [
r: :repo
]
@switches [
change: :string,
repo: [:string, :keep],
no_compile: :boolean,
no_deps_check: :boolean,
migrations_path: :string
]
@moduledoc """
Generates a migration.
The repository must be set under `:ecto_repos` in the
current app configuration or given via the `-r` option.
## Examples
$ mix ecto.gen.migration add_posts_table
$ mix ecto.gen.migration add_posts_table -r Custom.Repo
The generated migration filename will be prefixed with the current
timestamp in UTC which is used for versioning and ordering.
By default, the migration will be generated to the
"priv/YOUR_REPO/migrations" directory of the current application
but it can be configured to be any subdirectory of `priv` by
specifying the `:priv` key under the repository configuration.
This generator will automatically open the generated file if
you have `ECTO_EDITOR` set in your environment variable.
## Command line options
* `-r`, `--repo` - the repo to generate migration for
* `--no-compile` - does not compile applications before running
* `--no-deps-check` - does not check dependencies before running
* `--migrations-path` - the path to run the migrations from, defaults to `priv/repo/migrations`
## Configuration
If the current app configuration specifies a custom migration module
the generated migration code will use that rather than the default
`Ecto.Migration`:
config :ecto_sql, migration_module: MyApplication.CustomMigrationModule
"""
@impl true
def run(args) do
repos = parse_repo(args)
Enum.map(repos, fn repo ->
case OptionParser.parse!(args, strict: @switches, aliases: @aliases) do
{opts, [name]} ->
ensure_repo(repo, args)
path = opts[:migrations_path] || Path.join(source_repo_priv(repo), "migrations")
base_name = "#{underscore(name)}.exs"
file = Path.join(path, "#{timestamp()}_#{base_name}")
unless File.dir?(path), do: create_directory(path)
fuzzy_path = Path.join(path, "*_#{base_name}")
if Path.wildcard(fuzzy_path) != [] do
Mix.raise(
"migration can't be created, there is already a migration file with name #{name}."
)
end
# The :change option may be used by other tasks but not the CLI
assigns = [
mod: Module.concat([repo, Migrations, camelize(name)]),
change: opts[:change]
]
create_file(file, migration_template(assigns))
if open?(file) and Mix.shell().yes?("Do you want to run this migration?") do
Mix.Task.run("ecto.migrate", ["-r", inspect(repo), "--migrations-path", path])
end
file
{_, _} ->
Mix.raise(
"expected ecto.gen.migration to receive the migration file name, " <>
"got: #{inspect(Enum.join(args, " "))}"
)
end
end)
end
defp timestamp do
{{y, m, d}, {hh, mm, ss}} = :calendar.universal_time()
"#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}"
end
defp pad(i) when i < 10, do: <<?0, ?0 + i>>
defp pad(i), do: to_string(i)
defp migration_module do
case Application.get_env(:ecto_sql, :migration_module, Ecto.Migration) do
migration_module when is_atom(migration_module) -> migration_module
other -> Mix.raise("Expected :migration_module to be a module, got: #{inspect(other)}")
end
end
embed_template(:migration, """
defmodule <%= inspect @mod %> do
use <%= inspect migration_module() %>
def change do
<%= @change %>
end
end
""")
end

View File

@@ -0,0 +1,146 @@
defmodule Mix.Tasks.Ecto.Load do
use Mix.Task
import Mix.Ecto
import Mix.EctoSQL
@shortdoc "Loads previously dumped database structure"
@default_opts [force: false, quiet: false]
@aliases [
d: :dump_path,
f: :force,
q: :quiet,
r: :repo
]
@switches [
dump_path: :string,
force: :boolean,
quiet: :boolean,
repo: [:string, :keep],
no_compile: :boolean,
no_deps_check: :boolean,
skip_if_loaded: :boolean
]
@moduledoc """
Loads the current environment's database structure for the
given repository from a previously dumped structure file.
The repository must be set under `:ecto_repos` in the
current app configuration or given via the `-r` option.
This task needs some shell utility to be present on the machine
running the task.
Database | Utility needed
:--------- | :-------------
PostgreSQL | psql
MySQL | mysql
## Example
$ mix ecto.load
## Command line options
* `-r`, `--repo` - the repo to load the structure info into
* `-d`, `--dump-path` - the path of the dump file to load from
* `-q`, `--quiet` - run the command quietly
* `-f`, `--force` - do not ask for confirmation when loading data.
Configuration is asked only when `:start_permanent` is set to true
(typically in production)
* `--no-compile` - does not compile applications before loading
* `--no-deps-check` - does not check dependencies before loading
* `--skip-if-loaded` - does not load the dump file if the repo has the migrations table up
"""
@impl true
def run(args, table_exists? \\ &Ecto.Adapters.SQL.table_exists?/3) do
{opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
opts = Keyword.merge(@default_opts, opts)
opts = if opts[:quiet], do: Keyword.put(opts, :log, false), else: opts
Enum.each(parse_repo(args), fn repo ->
ensure_repo(repo, args)
ensure_implements(
repo.__adapter__(),
Ecto.Adapter.Structure,
"load structure for #{inspect(repo)}"
)
{migration_repo, source} =
Ecto.Migration.SchemaMigration.get_repo_and_source(repo, repo.config())
{:ok, loaded?, _} =
Ecto.Migrator.with_repo(migration_repo, table_exists_closure(table_exists?, source, opts))
for repo <- Enum.uniq([repo, migration_repo]) do
cond do
loaded? and opts[:skip_if_loaded] ->
:ok
(skip_safety_warnings?() and not loaded?) or opts[:force] or confirm_load(repo, loaded?) ->
load_structure(repo, opts)
true ->
:ok
end
end
end)
end
defp table_exists_closure(fun, source, opts) when is_function(fun, 3) do
&fun.(&1, source, opts)
end
defp table_exists_closure(fun, source, _opts) when is_function(fun, 2) do
&fun.(&1, source)
end
defp skip_safety_warnings? do
Mix.Project.config()[:start_permanent] != true
end
defp confirm_load(repo, false) do
Mix.shell().yes?(
"Are you sure you want to load a new structure for #{inspect(repo)}? Any existing data in this repo may be lost."
)
end
defp confirm_load(repo, true) do
Mix.shell().yes?("""
It looks like a structure was already loaded for #{inspect(repo)}. Any attempt to load it again might fail.
Are you sure you want to proceed?
""")
end
defp load_structure(repo, opts) do
config = Keyword.merge(repo.config(), opts)
start_time = System.system_time()
case repo.__adapter__().structure_load(source_repo_priv(repo), config) do
{:ok, location} ->
unless opts[:quiet] do
elapsed =
System.convert_time_unit(System.system_time() - start_time, :native, :microsecond)
Mix.shell().info(
"The structure for #{inspect(repo)} has been loaded from #{location} in #{format_time(elapsed)}"
)
end
{:error, term} when is_binary(term) ->
Mix.raise("The structure for #{inspect(repo)} couldn't be loaded: #{term}")
{:error, term} ->
Mix.raise("The structure for #{inspect(repo)} couldn't be loaded: #{inspect(term)}")
end
end
defp format_time(microsec) when microsec < 1_000, do: "#{microsec} μs"
defp format_time(microsec) when microsec < 1_000_000, do: "#{div(microsec, 1_000)} ms"
defp format_time(microsec), do: "#{Float.round(microsec / 1_000_000.0)} s"
end

View File

@@ -0,0 +1,162 @@
defmodule Mix.Tasks.Ecto.Migrate do
use Mix.Task
import Mix.Ecto
import Mix.EctoSQL
@shortdoc "Runs the repository migrations"
@aliases [
n: :step,
r: :repo
]
@switches [
all: :boolean,
step: :integer,
to: :integer,
to_exclusive: :integer,
quiet: :boolean,
prefix: :string,
pool_size: :integer,
log_level: :string,
log_migrations_sql: :boolean,
log_migrator_sql: :boolean,
strict_version_order: :boolean,
repo: [:keep, :string],
no_compile: :boolean,
no_deps_check: :boolean,
migrations_path: :keep
]
@moduledoc """
Runs the pending migrations for the given repository.
Migrations are expected at "priv/YOUR_REPO/migrations" directory
of the current application, where "YOUR_REPO" is the last segment
in your repository name. For example, the repository `MyApp.Repo`
will use "priv/repo/migrations". The repository `Whatever.MyRepo`
will use "priv/my_repo/migrations".
You can configure a repository to use another directory by specifying
the `:priv` key under the repository configuration. The "migrations"
part will be automatically appended to it. For instance, to use
"priv/custom_repo/migrations":
config :my_app, MyApp.Repo, priv: "priv/custom_repo"
This task runs all pending migrations by default. To migrate up to a
specific version number, supply `--to version_number`. To migrate a
specific number of times, use `--step n`.
The repositories to migrate are the ones specified under the
`:ecto_repos` option in the current app configuration. However,
if the `-r` option is given, it replaces the `:ecto_repos` config.
Since Ecto tasks can only be executed once, if you need to migrate
multiple repositories, set `:ecto_repos` accordingly or pass the `-r`
flag multiple times.
If a repository has not yet been started, one will be started outside
your application supervision tree and shutdown afterwards.
## Examples
$ mix ecto.migrate
$ mix ecto.migrate -r Custom.Repo
$ mix ecto.migrate -n 3
$ mix ecto.migrate --step 3
$ mix ecto.migrate --to 20080906120000
## Command line options
* `--all` - run all pending migrations
* `--log-migrations-sql` - log SQL generated by migration commands
* `--log-migrator-sql` - log SQL generated by the migrator, such as
transactions, table locks, etc
* `--log-level` (since v3.11.0) - the level to set for `Logger`. This task
does not start your application, so whatever level you have configured in
your config files will not be used. If this is not provided, no level
will be set, so that if you set it yourself before calling this task
then this won't interfere. Can be any of the `t:Logger.level/0` levels
* `--migrations-path` - the path to load the migrations from, defaults to
`"priv/repo/migrations"`. This option may be given multiple times in which
case the migrations are loaded from all the given directories and sorted
as if they were in the same one
* `--no-compile` - does not compile applications before migrating
* `--no-deps-check` - does not check dependencies before migrating
* `--pool-size` - the pool size if the repository is started
only for the task (defaults to 2)
* `--prefix` - the prefix to run migrations on
* `--quiet` - do not log migration commands
* `-r`, `--repo` - the repo to migrate
* `--step`, `-n` - run n number of pending migrations
* `--strict-version-order` - abort when applying a migration with old
timestamp (otherwise it emits a warning)
* `--to` - run all migrations up to and including version
* `--to-exclusive` - run all migrations up to and excluding version
"""
@impl true
def run(args, migrator \\ &Ecto.Migrator.run/4) do
repos = parse_repo(args)
{opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
opts =
if opts[:to] || opts[:to_exclusive] || opts[:step] || opts[:all],
do: opts,
else: Keyword.put(opts, :all, true)
opts =
if opts[:quiet],
do: Keyword.merge(opts, log: false, log_migrations_sql: false, log_migrator_sql: false),
else: opts
if log_level = opts[:log_level] do
Logger.configure(level: String.to_existing_atom(log_level))
end
# Start ecto_sql explicitly before as we don't need
# to restart those apps if migrated.
{:ok, _} = Application.ensure_all_started(:ecto_sql)
for repo <- repos do
ensure_repo(repo, args)
paths = ensure_migrations_paths(repo, opts)
pool = repo.config()[:pool]
fun =
if Code.ensure_loaded?(pool) and function_exported?(pool, :unboxed_run, 2) do
&pool.unboxed_run(&1, fn -> migrator.(&1, paths, :up, opts) end)
else
&migrator.(&1, paths, :up, opts)
end
case Ecto.Migrator.with_repo(repo, fun, [mode: :temporary] ++ opts) do
{:ok, _migrated, _apps} ->
:ok
{:error, error} ->
Mix.raise("Could not start repo #{inspect(repo)}, error: #{inspect(error)}")
end
end
:ok
end
end

View File

@@ -0,0 +1,97 @@
defmodule Mix.Tasks.Ecto.Migrations do
use Mix.Task
import Mix.Ecto
import Mix.EctoSQL
@shortdoc "Displays the repository migration status"
@aliases [
r: :repo
]
@switches [
repo: [:keep, :string],
no_compile: :boolean,
no_deps_check: :boolean,
migrations_path: :keep,
prefix: :string
]
@moduledoc """
Displays the up / down migration status for the given repository.
The repository must be set under `:ecto_repos` in the
current app configuration or given via the `-r` option.
By default, migrations are expected at "priv/YOUR_REPO/migrations"
directory of the current application but it can be configured
by specifying the `:priv` key under the repository configuration.
If the repository has not been started yet, one will be
started outside our application supervision tree and shutdown
afterwards.
## Examples
$ mix ecto.migrations
$ mix ecto.migrations -r Custom.Repo
## Command line options
* `--migrations-path` - the path to load the migrations from, defaults to
`"priv/repo/migrations"`. This option may be given multiple times in which
case the migrations are loaded from all the given directories and sorted as
if they were in the same one.
Note, if you have previously run migrations from paths `a/` and `b/`, and now
run `mix ecto.migrations --migrations-path a/` (omitting path `b/`), the
migrations from the path `b/` will be shown in the output as `** FILE NOT FOUND **`.
* `--no-compile` - does not compile applications before running
* `--no-deps-check` - does not check dependencies before running
* `--prefix` - the prefix to check migrations on
* `-r`, `--repo` - the repo to obtain the status for
"""
@impl true
def run(args, migrations \\ &Ecto.Migrator.migrations/3, puts \\ &IO.puts/1) do
repos = parse_repo(args)
{opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
for repo <- repos do
ensure_repo(repo, args)
paths = ensure_migrations_paths(repo, opts)
case Ecto.Migrator.with_repo(repo, &migrations.(&1, paths, opts), mode: :temporary) do
{:ok, repo_status, _} ->
puts.(
"""
Repo: #{inspect(repo)}
Status Migration ID Migration Name
--------------------------------------------------
""" <>
Enum.map_join(repo_status, "\n", fn {status, number, description} ->
" #{format(status, 10)}#{format(number, 16)}#{description}"
end) <> "\n"
)
{:error, error} ->
Mix.raise("Could not start repo #{inspect(repo)}, error: #{inspect(error)}")
end
end
:ok
end
defp format(content, pad) do
content
|> to_string
|> String.pad_trailing(pad)
end
end

View File

@@ -0,0 +1,158 @@
defmodule Mix.Tasks.Ecto.Rollback do
use Mix.Task
import Mix.Ecto
import Mix.EctoSQL
@shortdoc "Rolls back the repository migrations"
@aliases [
r: :repo,
n: :step
]
@switches [
all: :boolean,
step: :integer,
to: :integer,
to_exclusive: :integer,
quiet: :boolean,
prefix: :string,
pool_size: :integer,
log_level: :string,
log_migrations_sql: :boolean,
log_migrator_sql: :boolean,
repo: [:keep, :string],
no_compile: :boolean,
no_deps_check: :boolean,
migrations_path: :keep
]
@moduledoc """
Reverts applied migrations in the given repository.
Migrations are expected at "priv/YOUR_REPO/migrations" directory
of the current application, where "YOUR_REPO" is the last segment
in your repository name. For example, the repository `MyApp.Repo`
will use "priv/repo/migrations". The repository `Whatever.MyRepo`
will use "priv/my_repo/migrations".
You can configure a repository to use another directory by specifying
the `:priv` key under the repository configuration. The "migrations"
part will be automatically appended to it. For instance, to use
"priv/custom_repo/migrations":
config :my_app, MyApp.Repo, priv: "priv/custom_repo"
This task rolls back the last applied migration by default. To roll
back to a version number, supply `--to version_number`. To roll
back a specific number of times, use `--step n`. To undo all applied
migrations, provide `--all`.
The repositories to rollback are the ones specified under the
`:ecto_repos` option in the current app configuration. However,
if the `-r` option is given, it replaces the `:ecto_repos` config.
If a repository has not yet been started, one will be started outside
your application supervision tree and shutdown afterwards.
## Examples
$ mix ecto.rollback
$ mix ecto.rollback -r Custom.Repo
$ mix ecto.rollback -n 3
$ mix ecto.rollback --step 3
$ mix ecto.rollback --to 20080906120000
## Command line options
* `--all` - run all pending migrations
* `--log-migrations-sql` - log SQL generated by migration commands
* `--log-migrator-sql` - log SQL generated by the migrator, such as
transactions, table locks, etc
* `--log-level` (since v3.11.0) - the level to set for `Logger`. This task
does not start your application, so whatever level you have configured in
your config files will not be used. If this is not provided, no level
will be set, so that if you set it yourself before calling this task
then this won't interfere. Can be any of the `t:Logger.level/0` levels
* `--migrations-path` - the path to load the migrations from, defaults to
`"priv/repo/migrations"`. This option may be given multiple times in which
case the migrations are loaded from all the given directories and sorted
as if they were in the same one
* `--no-compile` - does not compile applications before migrating
* `--no-deps-check` - does not check dependencies before migrating
* `--pool-size` - the pool size if the repository is started
only for the task (defaults to 2)
* `--prefix` - the prefix to run migrations on
* `--quiet` - do not log migration commands
* `-r`, `--repo` - the repo to migrate
* `--step`, `-n` - revert n migrations
* `--strict-version-order` - abort when applying a migration with old
timestamp (otherwise it emits a warning)
* `--to` - revert all migrations down to and including version
* `--to-exclusive` - revert all migrations down to and excluding version
"""
@impl true
def run(args, migrator \\ &Ecto.Migrator.run/4) do
repos = parse_repo(args)
{opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
opts =
if opts[:to] || opts[:to_exclusive] || opts[:step] || opts[:all],
do: opts,
else: Keyword.put(opts, :step, 1)
opts =
if opts[:quiet],
do: Keyword.merge(opts, log: false, log_migrations_sql: false, log_migrator_sql: false),
else: opts
if log_level = opts[:log_level] do
Logger.configure(level: String.to_existing_atom(log_level))
end
# Start ecto_sql explicitly before as we don't need
# to restart those apps if migrated.
{:ok, _} = Application.ensure_all_started(:ecto_sql)
for repo <- repos do
ensure_repo(repo, args)
paths = ensure_migrations_paths(repo, opts)
pool = repo.config()[:pool]
fun =
if Code.ensure_loaded?(pool) and function_exported?(pool, :unboxed_run, 2) do
&pool.unboxed_run(&1, fn -> migrator.(&1, paths, :down, opts) end)
else
&migrator.(&1, paths, :down, opts)
end
case Ecto.Migrator.with_repo(repo, fun, [mode: :temporary] ++ opts) do
{:ok, _migrated, _apps} ->
:ok
{:error, error} ->
Mix.raise("Could not start repo #{inspect(repo)}, error: #{inspect(error)}")
end
end
:ok
end
end

View File

@@ -0,0 +1,207 @@
defmodule EctoSQL.MixProject do
use Mix.Project
@source_url "https://github.com/elixir-ecto/ecto_sql"
@version "3.13.5"
@adapters ~w(pg myxql tds)
def project do
[
app: :ecto_sql,
version: @version,
elixir: "~> 1.14",
deps: deps(),
test_paths: test_paths(System.get_env("ECTO_ADAPTER")),
xref: [
exclude: [
MyXQL,
Ecto.Adapters.MyXQL.Connection,
Postgrex,
Ecto.Adapters.Postgres.Connection,
Tds,
Tds.Ecto.UUID,
Ecto.Adapters.Tds.Connection
]
],
# Custom testing
aliases: [
"test.all": ["test", "test.adapters", "test.as_a_dep"],
"test.adapters": &test_adapters/1,
"test.as_a_dep": &test_as_a_dep/1
],
preferred_cli_env: ["test.all": :test, "test.adapters": :test],
# Hex
description: "SQL-based adapters for Ecto and database migrations",
package: package(),
# Docs
name: "Ecto SQL",
docs: docs()
]
end
def application do
[
extra_applications: [:logger, :eex],
env: [postgres_map_type: "jsonb"],
mod: {Ecto.Adapters.SQL.Application, []}
]
end
defp deps do
[
ecto_dep(),
{:telemetry, "~> 0.4.0 or ~> 1.0"},
# Drivers
{:db_connection, "~> 2.5 or ~> 2.4.1"},
postgrex_dep(),
myxql_dep(),
tds_dep(),
# Bring something in for JSON during tests
{:jason, ">= 0.0.0", only: [:test, :docs]},
# Docs
{:ex_doc, "~> 0.21", only: :docs},
# Benchmarks
{:benchee, "~> 1.0", only: :bench}
]
end
defp ecto_dep do
if path = System.get_env("ECTO_PATH") do
{:ecto, path: path}
else
{:ecto, "~> 3.13.0"}
end
end
defp postgrex_dep do
if path = System.get_env("POSTGREX_PATH") do
{:postgrex, path: path}
else
{:postgrex, "~> 0.19 or ~> 1.0", optional: true}
end
end
defp myxql_dep do
if path = System.get_env("MYXQL_PATH") do
{:myxql, path: path}
else
{:myxql, "~> 0.7", optional: true}
end
end
defp tds_dep do
if path = System.get_env("TDS_PATH") do
{:tds, path: path}
else
{:tds, "~> 2.1.1 or ~> 2.2", optional: true}
end
end
defp test_paths(adapter) when adapter in @adapters, do: ["integration_test/#{adapter}"]
defp test_paths(nil), do: ["test"]
defp test_paths(other), do: raise("unknown adapter #{inspect(other)}")
defp package do
[
maintainers: ["Eric Meadows-Jönsson", "José Valim", "James Fish", "Michał Muskała"],
licenses: ["Apache-2.0"],
links: %{"GitHub" => @source_url},
files:
~w(.formatter.exs mix.exs README.md CHANGELOG.md lib) ++
~w(integration_test/sql integration_test/support)
]
end
defp test_as_a_dep(args) do
IO.puts("==> Compiling ecto_sql from a dependency")
File.rm_rf!("tmp/as_a_dep")
File.mkdir_p!("tmp/as_a_dep")
File.cd!("tmp/as_a_dep", fn ->
File.write!("mix.exs", """
defmodule DepsOnEctoSQL.MixProject do
use Mix.Project
def project do
[
app: :deps_on_ecto_sql,
version: "0.0.1",
deps: [{:ecto_sql, path: "../.."}]
]
end
end
""")
mix_cmd_with_status_check(["do", "deps.get,", "compile", "--force" | args])
end)
end
defp test_adapters(args) do
for adapter <- @adapters, do: env_run(adapter, args)
end
defp env_run(adapter, args) do
IO.puts("==> Running tests for ECTO_ADAPTER=#{adapter} mix test")
mix_cmd_with_status_check(
["test", ansi_option() | args],
env: [{"ECTO_ADAPTER", adapter}]
)
end
defp ansi_option do
if IO.ANSI.enabled?(), do: "--color", else: "--no-color"
end
defp mix_cmd_with_status_check(args, opts \\ []) do
{_, res} = System.cmd("mix", args, [into: IO.binstream(:stdio, :line)] ++ opts)
if res > 0 do
System.at_exit(fn _ -> exit({:shutdown, 1}) end)
end
end
defp docs do
[
main: "Ecto.Adapters.SQL",
source_ref: "v#{@version}",
canonical: "http://hexdocs.pm/ecto_sql",
source_url: @source_url,
extras: ["CHANGELOG.md"],
skip_undefined_reference_warnings_on: ["CHANGELOG.md"],
groups_for_modules: [
# Ecto.Adapters.SQL,
# Ecto.Adapters.SQL.Sandbox,
# Ecto.Migration,
# Ecto.Migrator,
"Built-in adapters": [
Ecto.Adapters.MyXQL,
Ecto.Adapters.Tds,
Ecto.Adapters.Postgres
],
"TDS Types": [
Tds.Ecto.UUID,
Tds.Ecto.VarChar
],
"Adapter specification": [
Ecto.Adapter.Migration,
Ecto.Adapter.Structure,
Ecto.Adapters.SQL.Connection,
Ecto.Migration.Command,
Ecto.Migration.Constraint,
Ecto.Migration.Index,
Ecto.Migration.Reference,
Ecto.Migration.Table
]
]
]
end
end