Skip to content

Commit c27adec

Browse files
authored
Add nulls_distinct option for unique indexes (#439)
1 parent 339bd67 commit c27adec

7 files changed

Lines changed: 76 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Enhancements
66

77
* [migrations] Support `primary_key` configuration options in `table`
8+
* [migrations] Add `:nulls_distinct` option for unique indexes
89
* [postgres] Support the use of advisory locks for migrations
910
* [sql] Add `dump_cmd` to `postgrex` and `myxql` adapters
1011
* [sql] Log human-readable UUIDs by using pre-dumped query parameters
@@ -22,7 +23,7 @@
2223
### Bug fixes
2324

2425
* [postgres] Fix possible breaking change on `json_extract_path` for boolean values introduced in v3.8.0
25-
* [sql] Colorize stacktrace and use `:` before printing line number
26+
* [sql] Colorize stacktrace and use `:` before printing line number
2627

2728
## v3.8.1 (2022-04-29)
2829

lib/ecto/adapters/myxql/connection.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,10 @@ if Code.ensure_loaded?(MyXQL) do
773773
error!(nil, "MySQL adapter does not support where in indexes")
774774
end
775775

776+
if index.nulls_distinct == false do
777+
error!(nil, "MySQL adapter does not support nulls_distinct set to false in indexes")
778+
end
779+
776780
[["CREATE", if_do(index.unique, " UNIQUE"), " INDEX ",
777781
quote_name(index.name),
778782
" ON ",

lib/ecto/adapters/postgres/connection.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,13 @@ if Code.ensure_loaded?(Postgrex) do
908908
fields = intersperse_map(index.columns, ", ", &index_expr/1)
909909
include_fields = intersperse_map(index.include, ", ", &index_expr/1)
910910

911+
maybe_nulls_distinct =
912+
case index.nulls_distinct do
913+
nil -> []
914+
true -> " NULLS DISTINCT"
915+
false -> " NULLS NOT DISTINCT"
916+
end
917+
911918
queries = [["CREATE ",
912919
if_do(index.unique, "UNIQUE "),
913920
"INDEX ",
@@ -919,6 +926,7 @@ if Code.ensure_loaded?(Postgrex) do
919926
if_do(index.using, [" USING " , to_string(index.using)]),
920927
?\s, ?(, fields, ?),
921928
if_do(include_fields != [], [" INCLUDE ", ?(, include_fields, ?)]),
929+
maybe_nulls_distinct,
922930
if_do(index.where, [" WHERE ", to_string(index.where)])]]
923931

924932
queries ++ comments_on("INDEX", quote_table(index.prefix, index.name), index.comment)

lib/ecto/adapters/tds/connection.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,10 @@ if Code.ensure_loaded?(Tds) do
10691069
error!(nil, "MSSQL does not support `using` in indexes")
10701070
end
10711071

1072+
if index.nulls_distinct == true do
1073+
error!(nil, "MSSQL does not support nulls_distinct set to true in indexes")
1074+
end
1075+
10721076
with_options =
10731077
if index.concurrently or index.options != nil do
10741078
[

lib/ecto/migration.ex

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ defmodule Ecto.Migration do
366366
concurrently: false,
367367
using: nil,
368368
include: [],
369+
nulls_distinct: nil,
369370
where: nil,
370371
comment: nil,
371372
options: nil
@@ -379,6 +380,7 @@ defmodule Ecto.Migration do
379380
concurrently: boolean,
380381
using: atom | String.t,
381382
include: [atom | String.t],
383+
nulls_distinct: boolean | nil,
382384
where: atom | String.t,
383385
comment: String.t | nil,
384386
options: String.t
@@ -705,6 +707,12 @@ defmodule Ecto.Migration do
705707
* `:include` - specify fields for a covering index. This is not supported
706708
by all databases. For more information on PostgreSQL support, please
707709
[read the official docs](https://www.postgresql.org/docs/current/indexes-index-only-scans.html).
710+
* `:nulls_distinct` - specify whether null values should be considered
711+
distinct for a unique index. Defaults to `nil`, which will not add the
712+
parameter to the generated SQL and thus use the database default.
713+
This option is currently only supported by PostgreSQL 15+.
714+
For MySQL, it is always false. For MSSQL, it is always true.
715+
See the dedicated section on this option for more information.
708716
* `:comment` - adds a comment to the index.
709717
710718
## Adding/dropping indexes concurrently
@@ -763,6 +771,29 @@ defmodule Ecto.Migration do
763771
More information on partial indexes can be found in the [PostgreSQL
764772
docs](http://www.postgresql.org/docs/current/indexes-partial.html).
765773
774+
## The `:nulls_distinct` option
775+
776+
A unique index does not prevent multiple null values by default in most databases.
777+
778+
For example, imagine we have a "products" table and need to guarantee that
779+
sku's are unique within their category, but the category is optional.
780+
Creating a regular unique index over the sku and category_id fields with:
781+
782+
create index("products", [:sku, :category_id], unique: true)
783+
784+
will allow products with the same sku to be inserted if their category_id is `nil`.
785+
The `:nulls_distinct` option can be used to change this behavior by considering
786+
null values as equal, i.e. not distinct:
787+
788+
create index("products", [:sku, :category_id], unique: true, nulls_distinct: false)
789+
790+
This option is currently only supported by PostgreSQL 15+.
791+
As a workaround for older PostgreSQL versions and other databases, an
792+
additional partial unique index for the sku can be created:
793+
794+
create index("products", [:sku, :category_id], unique: true)
795+
create index("products", [:sku], unique: true, where: "category_id IS NULL")
796+
766797
## Examples
767798
768799
# With no name provided, the name of the below index defaults to
@@ -1343,6 +1374,10 @@ defmodule Ecto.Migration do
13431374
end
13441375

13451376
defp validate_index_opts!(opts) when is_list(opts) do
1377+
if opts[:nulls_distinct] != nil and opts[:unique] != true do
1378+
raise ArgumentError, "the `nulls_distinct` option can only be used with unique indexes"
1379+
end
1380+
13461381
case Keyword.get_values(opts, :where) do
13471382
[_, _ | _] ->
13481383
raise ArgumentError,

test/ecto/adapters/postgres_test.exs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,20 @@ defmodule Ecto.Adapters.PostgresTest do
17791779
[~s|CREATE UNIQUE INDEX "posts_permalink_index" ON "posts" ("permalink") INCLUDE ("public") WHERE public IS TRUE|]
17801780
end
17811781

1782+
test "create unique index with nulls_distinct option" do
1783+
create = {:create, index(:posts, [:permalink], unique: true, nulls_distinct: true)}
1784+
assert execute_ddl(create) ==
1785+
[~s|CREATE UNIQUE INDEX "posts_permalink_index" ON "posts" ("permalink") NULLS DISTINCT|]
1786+
1787+
create = {:create, index(:posts, [:permalink], unique: true, nulls_distinct: false)}
1788+
assert execute_ddl(create) ==
1789+
[~s|CREATE UNIQUE INDEX "posts_permalink_index" ON "posts" ("permalink") NULLS NOT DISTINCT|]
1790+
1791+
create = {:create, index(:posts, [:permalink], unique: true, nulls_distinct: false, include: [:public], where: "public IS TRUE")}
1792+
assert execute_ddl(create) ==
1793+
[~s|CREATE UNIQUE INDEX "posts_permalink_index" ON "posts" ("permalink") INCLUDE ("public") NULLS NOT DISTINCT WHERE public IS TRUE|]
1794+
end
1795+
17821796
test "create index concurrently" do
17831797
index = index(:posts, [:permalink])
17841798
create = {:create, %{index | concurrently: true}}

test/ecto/migration_test.exs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ defmodule Ecto.MigrationTest do
7070
%Index{table: "table_one__table_two", unique: true, name: :table_one__table_two_title_index, columns: [:title]}
7171
end
7272

73+
test "raises if nulls_distinct is set for a non-unique index" do
74+
assert_raise ArgumentError, fn ->
75+
index(:posts, [:title], unique: false, nulls_distinct: false)
76+
end
77+
assert_raise ArgumentError, fn ->
78+
index(:posts, [:title], unique: false, nulls_distinct: true)
79+
end
80+
end
81+
7382
test "raises if given multiple 'where' clauses for an index" do
7483
assert_raise ArgumentError, fn ->
7584
index(:posts, [:title], where: "status = 'published'", where: "deleted = 'false'")

0 commit comments

Comments
 (0)