diff --git a/.formatter.exs b/.formatter.exs index c57f31c..786ee5d 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,6 +1,7 @@ spark_locals_without_parens = [ all_tenants?: 1, base_filter_sql: 1, + calculations_to_sql: 1, check: 1, check_constraint: 2, check_constraint: 3, @@ -13,6 +14,7 @@ spark_locals_without_parens = [ exclusion_constraint_names: 1, foreign_key_names: 1, identity_index_names: 1, + identity_wheres_to_sql: 1, ignore?: 1, include: 1, index: 1, diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e644c..531ba07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,46 +7,37 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline ## [v2.0.4](https://github.com/ash-project/ash_postgres/compare/v2.0.3...v2.0.4) (2024-05-23) - - - ### Bug Fixes: -* ensure update's reselect all changing values +[updates] ensure update's reselect all changing values ## [v2.0.3](https://github.com/ash-project/ash_postgres/compare/v2.0.2...v2.0.3) (2024-05-22) - - - ### Bug Fixes: -* handle complex maps/list on update +[updates] handle complex maps/list on update -* support anonymous aggregates in sorts +[Ash.Query] support anonymous aggregates in sorts -* ensure parent_as bindings properly reference binding names +[exists] ensure parent_as bindings properly reference binding names -* add and remove custom indexes in tandem properly +[migration generator] add and remove custom indexes in tandem properly ### Improvements: -* support `on_delete: :nilify` for specific columns (#289) +[references] support `on_delete: :nilify` for specific columns (#289) ## [v2.0.2](https://github.com/ash-project/ash_postgres/compare/v2.0.1...v2.0.2) (2024-05-15) - - - ### Bug Fixes: -* [update_query/destroy_query] rework the update and destroy query builder to support multiple kinds of joining +- [update_query/destroy_query] rework the update and destroy query builder to support multiple kinds of joining -* [mix ash_postgres.migrate] remove duplicate repo flags (#285) +- [mix ash_postgres.migrate] remove duplicate repo flags (#285) -* [Ash.Error.Changes.StaleRecord] ensure filter is included in stale record error messages we return +- [Ash.Error.Changes.StaleRecord] ensure filter is included in stale record error messages we return -* [AshPostgres.MigrationGenerator] properly parse previous version from migration generation +- [AshPostgres.MigrationGenerator] properly parse previous version from migration generation ## [v2.0.1](https://github.com/ash-project/ash_postgres/compare/v2.0.0...v2.0.1) (2024-05-12) diff --git a/documentation/dsls/DSL:-AshPostgres.DataLayer.md b/documentation/dsls/DSL:-AshPostgres.DataLayer.md index 820c0b4..7d6cde4 100644 --- a/documentation/dsls/DSL:-AshPostgres.DataLayer.md +++ b/documentation/dsls/DSL:-AshPostgres.DataLayer.md @@ -42,6 +42,8 @@ end | [`migrate?`](#postgres-migrate?){: #postgres-migrate? } | `boolean` | `true` | Whether or not to include this resource in the generated migrations with `mix ash.generate_migrations` | | [`migration_types`](#postgres-migration_types){: #postgres-migration_types } | `keyword` | `[]` | A keyword list of attribute names to the ecto migration type that should be used for that attribute. Only necessary if you need to override the defaults. | | [`migration_defaults`](#postgres-migration_defaults){: #postgres-migration_defaults } | `keyword` | `[]` | A keyword list of attribute names to the ecto migration default that should be used for that attribute. The string you use will be placed verbatim in the migration. Use fragments like `fragment(\\"now()\\")`, or for `nil`, use `\\"nil\\"`. | +| [`calculations_to_sql`](#postgres-calculations_to_sql){: #postgres-calculations_to_sql } | `keyword` | | A keyword list of calculations and their SQL representation. Used when creating unique indexes for identities over calculations | +| [`identity_wheres_to_sql`](#postgres-identity_wheres_to_sql){: #postgres-identity_wheres_to_sql } | `keyword` | | A keyword list of identity names and the SQL representation of their `where` clause. Used when creating unique indexes for identities over calculations | | [`base_filter_sql`](#postgres-base_filter_sql){: #postgres-base_filter_sql } | `String.t` | | A raw sql version of the base_filter, e.g `representative = true`. Required if trying to create a unique constraint on a resource with a base_filter | | [`simple_join_first_aggregates`](#postgres-simple_join_first_aggregates){: #postgres-simple_join_first_aggregates } | `list(atom)` | `[]` | A list of `:first` type aggregate names that can be joined to using a simple join. Use when you have a `:first` aggregate that uses a to-many relationship , but your `filter` statement ensures that there is only one result. Optimizes the generated query. | | [`skip_unique_indexes`](#postgres-skip_unique_indexes){: #postgres-skip_unique_indexes } | `atom \| list(atom)` | `false` | Skip generating unique indexes when generating migrations | diff --git a/lib/data_layer.ex b/lib/data_layer.ex index c00f489..10839a4 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -300,6 +300,16 @@ defmodule AshPostgres.DataLayer do A keyword list of attribute names to the ecto migration default that should be used for that attribute. The string you use will be placed verbatim in the migration. Use fragments like `fragment(\\\\"now()\\\\")`, or for `nil`, use `\\\\"nil\\\\"`. """ ], + calculations_to_sql: [ + type: :keyword_list, + doc: + "A keyword list of calculations and their SQL representation. Used when creating unique indexes for identities over calculations" + ], + identity_wheres_to_sql: [ + type: :keyword_list, + doc: + "A keyword list of identity names and the SQL representation of their `where` clause. Used when creating unique indexes for identities over calculations" + ], base_filter_sql: [ type: :string, doc: diff --git a/lib/data_layer/info.ex b/lib/data_layer/info.ex index 8ed9f36..b16c252 100644 --- a/lib/data_layer/info.ex +++ b/lib/data_layer/info.ex @@ -14,6 +14,24 @@ defmodule AshPostgres.DataLayer.Info do end end + @doc "A keyword list of calculations to their sql representation" + def calculations_to_sql(resource) do + Extension.get_opt(resource, [:postgres], :calculations_to_sql, []) + end + + def calculation_to_sql(resource, calc) do + calculations_to_sql(resource)[calc] + end + + @doc "A keyword list of identity names to the sql representation of their where clauses" + def identity_wheres_to_sql(resource) do + Extension.get_opt(resource, [:postgres], :identity_wheres_to_sql, []) + end + + def identity_where_to_sql(resource, identity) do + identity_wheres_to_sql(resource)[identity] + end + @doc "Checks a version requirement against the resource's repo's postgres version" def pg_version_matches?(resource, requirement) do resource diff --git a/lib/migration_generator/migration_generator.ex b/lib/migration_generator/migration_generator.ex index e00041c..eefe374 100644 --- a/lib/migration_generator/migration_generator.ex +++ b/lib/migration_generator/migration_generator.ex @@ -1817,7 +1817,9 @@ defmodule AshPostgres.MigrationGenerator do old_identity.name == identity.name && Enum.sort(old_identity.keys) == Enum.sort(identity.keys) && old_identity.base_filter == identity.base_filter && - old_identity.all_tenants? == identity.all_tenants? + old_identity.all_tenants? == identity.all_tenants? && + old_identity.nils_distinct? == identity.nils_distinct? && + old_identity.where == identity.where end) else false @@ -1829,7 +1831,9 @@ defmodule AshPostgres.MigrationGenerator do old_identity.name == identity.name && Enum.sort(old_identity.keys) == Enum.sort(identity.keys) && old_identity.base_filter == identity.base_filter && - old_identity.all_tenants? == identity.all_tenants? + old_identity.all_tenants? == identity.all_tenants? && + old_identity.nils_distinct? == identity.nils_distinct? && + old_identity.where == identity.where end) end) end @@ -2820,23 +2824,32 @@ defmodule AshPostgres.MigrationGenerator do |> Enum.reject(fn identity -> identity.name in AshPostgres.DataLayer.Info.skip_unique_indexes(resource) end) - |> Enum.filter(fn identity -> - Enum.all?(identity.keys, fn key -> - Ash.Resource.Info.attribute(resource, key) - end) - end) |> Enum.sort_by(& &1.name) - |> Enum.map(&Map.take(&1, [:name, :keys, :all_tenants?])) |> Enum.map(fn %{keys: keys} = identity -> %{ identity | keys: Enum.map(keys, fn key -> - attribute = Ash.Resource.Info.attribute(resource, key) - attribute.source || attribute.name + case Ash.Resource.Info.field(resource, key) do + %Ash.Resource.Attribute{} = attribute -> + to_string(attribute.source || attribute.name) + + %Ash.Resource.Calculation{} -> + AshPostgres.DataLayer.Info.calculation_to_sql(resource, key) || + raise "Must define an entry for :#{key} in `postgres.calculations_to_sql`, or skip this identity with `postgres.skip_unique_indexes`" + end end) + |> Enum.sort(), + where: + if identity.where do + AshPostgres.DataLayer.Info.identity_where_to_sql(resource, identity.name) || + raise( + "Must provide an entry for :#{identity.name} in `postgres.identity_wheres_to_sql`, or skip this identity with `postgres.skip_unique_indexes`" + ) + end } end) + |> Enum.map(&Map.take(&1, [:name, :keys, :where, :all_tenants?, :nils_distinct?])) |> Enum.map(fn identity -> Map.put( identity, @@ -3179,13 +3192,13 @@ defmodule AshPostgres.MigrationGenerator do identity |> Map.update!(:name, &maybe_to_atom/1) |> Map.update!(:keys, fn keys -> - keys - |> Enum.map(&maybe_to_atom/1) - |> Enum.sort() + Enum.sort(keys) end) |> add_index_name(table) |> Map.put_new(:base_filter, nil) |> Map.put_new(:all_tenants?, false) + |> Map.put_new(:where, nil) + |> Map.put_new(:nils_distinct?, true) end defp add_index_name(%{name: name} = index, table) do diff --git a/lib/migration_generator/operation.ex b/lib/migration_generator/operation.ex index 5500525..97f225b 100644 --- a/lib/migration_generator/operation.ex +++ b/lib/migration_generator/operation.ex @@ -30,7 +30,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do # sobelow_skip ["DOS.StringToAtom"] def as_atom(value), do: Macro.inspect_atom(:remote_call, String.to_atom(value)) - def option(:nulls_distinct = key, value) do + def option(key, value) when key in [:nulls_distinct, "nulls_distinct"] do if !value do "#{as_atom(key)}: #{inspect(value)}" end @@ -790,6 +790,8 @@ defmodule AshPostgres.MigrationGenerator.Operation do identity: %{ name: name, keys: keys, + nils_distinct?: nils_distinct?, + where: where, base_filter: base_filter, index_name: index_name, all_tenants?: all_tenants? @@ -813,10 +815,17 @@ defmodule AshPostgres.MigrationGenerator.Operation do index_name = index_name || "#{table}_#{name}_index" - if base_filter do - "create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], where: \"#{base_filter}\", #{join(["name: \"#{index_name}\"", option("prefix", schema)])})" - else - "create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{join(["name: \"#{index_name}\"", option("prefix", schema)])})" + cond do + base_filter && where -> + where = "(#{where}) AND (#{base_filter})" + + "create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{join(["name: \"#{index_name}\"", option("prefix", schema), option("nulls_distinct", nils_distinct?), option("where", where)])})" + + base_filter -> + "create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], where: \"#{base_filter}\", #{join(["name: \"#{index_name}\"", option("prefix", schema), option("nulls_distinct", nils_distinct?)])})" + + true -> + "create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{join(["name: \"#{index_name}\"", option("prefix", schema), option("nulls_distinct", nils_distinct?), option("where", where)])})" end end diff --git a/mix.exs b/mix.exs index 014eea2..3920eb4 100644 --- a/mix.exs +++ b/mix.exs @@ -162,7 +162,7 @@ defmodule AshPostgres.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, ash_version("~> 3.0 and >= 3.0.6")}, + {:ash, ash_version("~> 3.0 and >= 3.0.7")}, {:ash_sql, ash_sql_version("~> 0.1 and >= 0.1.3")}, {:ecto_sql, "~> 3.9"}, {:ecto, "~> 3.9"}, diff --git a/mix.lock b/mix.lock index 11fcf21..bd556f8 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "ash": {:hex, :ash, "3.0.6", "888a5b81a0106e7122a487ea55cd968d8acfc5ac85a22c876eef7ffea9083041", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, ">= 0.8.1 and < 1.0.0-0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.1.18 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c3d529933cfe53fb9e018f04cc7106eef74d74872edee1288d0ba75d9e97202d"}, + "ash": {:hex, :ash, "3.0.7", "6c37e092f53b1b21eb89596f600a652b2a601f84378f44fd5dd1cdec72eb1cc2", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, ">= 0.8.1 and < 1.0.0-0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.1.18 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9288ddb50fe727096c6f63fd82c631de2505dcd29bdfa50b5dc13c865f0bf434"}, "ash_sql": {:hex, :ash_sql, "0.1.3", "c9acc4809b7f253aad31764024aee0cd632077a32cff6bea3b105c7b8d9015b7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "d2d3d1044f0fa48454d0cdaeb22d55a2de3210d48a2208fd2eecf6f3007a5216"}, "benchee": {:hex, :benchee, "1.3.0", "f64e3b64ad3563fa9838146ddefb2d2f94cf5b473bdfd63f5ca4d0657bf96694", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "34f4294068c11b2bd2ebf2c59aac9c7da26ffa0068afdf3419f1b176e16c5f81"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, @@ -30,7 +30,7 @@ "reactor": {:hex, :reactor, "0.8.2", "b2be82b1c3402537d06a8f85bb1849f72cb6b4be140495cb8956de7aec2fdebd", [:mix], [{:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c35eb23b77cc77ba922af108722ac93257899e35cfdd18882f0e659ad2cac9f3"}, "simple_sat": {:hex, :simple_sat, "0.1.3", "f650fc3c184a5fe741868b5ac56dc77fdbb428468f6dbf1978e14d0334497578", [:mix], [], "hexpm", "a54305066a356b7194dc81db2a89232bacdc0b3edaef68ed9aba28dcbc34887b"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, - "sourceror": {:hex, :sourceror, "1.2.0", "471232b2eb9ab930b90673d37cf005bbaec0ef02dadf5bf4c8c00c3d75a6c131", [:mix], [], "hexpm", "f01796ce1b87016573ce7b66073d6b48297c4d233982340340834269b8c95e51"}, + "sourceror": {:hex, :sourceror, "1.2.1", "b415255ad8bd05f0e859bb3d7ea617f6c2a4a405f2a534a231f229bd99b89f8b", [:mix], [], "hexpm", "e4d97087e67584a7585b5fe3d5a71bf8e7332f795dd1a44983d750003d5e750c"}, "spark": {:hex, :spark, "2.1.22", "a36400eede64c51af578de5fdb5a5aaa3e0811da44bcbe7545fce059bd2a990b", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f764611d0b15ac132e72b2326539acc11fc4e63baa3e429f541bca292b5f7064"}, "splode": {:hex, :splode, "0.2.4", "71046334c39605095ca4bed5d008372e56454060997da14f9868534c17b84b53", [:mix], [], "hexpm", "ca3b95f0d8d4b482b5357954fec857abd0fa3ea509d623334c1328e7382044c2"}, "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, diff --git a/priv/resource_snapshots/test_repo/posts/20240524031113.json b/priv/resource_snapshots/test_repo/posts/20240524031113.json new file mode 100644 index 0000000..068faaf --- /dev/null +++ b/priv/resource_snapshots/test_repo/posts/20240524031113.json @@ -0,0 +1,371 @@ +{ + "attributes": [ + { + "default": "fragment(\"gen_random_uuid()\")", + "size": null, + "type": "uuid", + "source": "id", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "title_column", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "timestamptz(6)", + "source": "datetime", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "bigint", + "source": "score", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "boolean", + "source": "public", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "citext", + "source": "category", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "\"sponsored\"", + "size": null, + "type": "text", + "source": "type", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "bigint", + "source": "price", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "\"0\"", + "size": null, + "type": "decimal", + "source": "decimal", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "status", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "status", + "source": "status_enum", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": [ + "array", + "float" + ], + "source": "point", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "custom_point", + "source": "composite_point", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "map", + "source": "stuff", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": [ + "array", + "map" + ], + "source": "list_of_stuff", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_one", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_two", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_custom_one", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_custom_two", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_on_upper", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": [ + "array", + "text" + ], + "source": "list_containing_nils", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "size": null, + "type": "utc_datetime_usec", + "source": "created_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "size": null, + "type": "timestamptz(6)", + "source": "updated_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "organization_id", + "references": { + "name": "posts_organization_id_fkey", + "table": "orgs", + "schema": "public", + "on_delete": null, + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "primary_key?": true, + "destination_attribute": "id", + "on_update": null, + "deferrable": false, + "match_type": null, + "match_with": null, + "destination_attribute_default": null, + "destination_attribute_generated": null + }, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "author_id", + "references": { + "name": "posts_author_id_fkey", + "table": "authors", + "schema": "public", + "on_delete": null, + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "primary_key?": true, + "destination_attribute": "id", + "on_update": null, + "deferrable": false, + "match_type": null, + "match_with": null, + "destination_attribute_default": null, + "destination_attribute_generated": null + }, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + } + ], + "table": "posts", + "hash": "A602EBEA21CE56CC203A0FC8EA66D35D5FD2DA646411088CE343EF793C9D3E9C", + "repo": "Elixir.AshPostgres.TestRepo", + "identities": [ + { + "name": "uniq_one_and_two", + "keys": [ + "uniq_one", + "uniq_two" + ], + "base_filter": "type = 'sponsored'", + "all_tenants?": false, + "index_name": "posts_uniq_one_and_two_index" + }, + { + "name": "uniq_on_upper", + "keys": [ + "UPPER(uniq_on_upper)" + ], + "base_filter": "type = 'sponsored'", + "all_tenants?": false, + "index_name": "posts_uniq_on_upper_index" + } + ], + "schema": null, + "check_constraints": [ + { + "name": "price_must_be_positive", + "check": "price > 0", + "attribute": [ + "price" + ], + "base_filter": "type = 'sponsored'" + } + ], + "custom_indexes": [ + { + "message": "dude what the heck", + "name": null, + "table": null, + "include": null, + "fields": [ + { + "type": "atom", + "value": "uniq_custom_one" + }, + { + "type": "atom", + "value": "uniq_custom_two" + } + ], + "prefix": null, + "where": null, + "unique": true, + "all_tenants?": false, + "concurrently": true, + "error_fields": [ + "uniq_custom_one", + "uniq_custom_two" + ], + "nulls_distinct": true, + "using": null + } + ], + "base_filter": "type = 'sponsored'", + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "custom_statements": [], + "has_create_action": true +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/posts/20240524041750.json b/priv/resource_snapshots/test_repo/posts/20240524041750.json new file mode 100644 index 0000000..ab0cbe8 --- /dev/null +++ b/priv/resource_snapshots/test_repo/posts/20240524041750.json @@ -0,0 +1,396 @@ +{ + "attributes": [ + { + "default": "fragment(\"gen_random_uuid()\")", + "size": null, + "type": "uuid", + "source": "id", + "references": null, + "generated?": false, + "primary_key?": true, + "allow_nil?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "title_column", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "timestamptz(6)", + "source": "datetime", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "bigint", + "source": "score", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "boolean", + "source": "public", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "citext", + "source": "category", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "\"sponsored\"", + "size": null, + "type": "text", + "source": "type", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "bigint", + "source": "price", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "\"0\"", + "size": null, + "type": "decimal", + "source": "decimal", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "status", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "status", + "source": "status_enum", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": [ + "array", + "float" + ], + "source": "point", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "custom_point", + "source": "composite_point", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "map", + "source": "stuff", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": [ + "array", + "map" + ], + "source": "list_of_stuff", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_one", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_two", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_custom_one", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_custom_two", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_on_upper", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "uniq_if_contains_foo", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": [ + "array", + "text" + ], + "source": "list_containing_nils", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "size": null, + "type": "utc_datetime_usec", + "source": "created_at", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": false + }, + { + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "size": null, + "type": "timestamptz(6)", + "source": "updated_at", + "references": null, + "generated?": false, + "primary_key?": false, + "allow_nil?": false + }, + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "organization_id", + "references": { + "name": "posts_organization_id_fkey", + "table": "orgs", + "on_delete": null, + "on_update": null, + "destination_attribute_generated": null, + "destination_attribute_default": null, + "primary_key?": true, + "destination_attribute": "id", + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "match_with": null, + "match_type": null, + "schema": "public", + "deferrable": false + }, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + }, + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "author_id", + "references": { + "name": "posts_author_id_fkey", + "table": "authors", + "on_delete": null, + "on_update": null, + "destination_attribute_generated": null, + "destination_attribute_default": null, + "primary_key?": true, + "destination_attribute": "id", + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "match_with": null, + "match_type": null, + "schema": "public", + "deferrable": false + }, + "generated?": false, + "primary_key?": false, + "allow_nil?": true + } + ], + "table": "posts", + "hash": "1C2BE60C682696F09AC2505B2B8844DFA449834F0E05B4C69D8E8F40B8C9CA89", + "repo": "Elixir.AshPostgres.TestRepo", + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "schema": null, + "base_filter": "type = 'sponsored'", + "custom_indexes": [ + { + "message": "dude what the heck", + "name": null, + "table": null, + "include": null, + "where": null, + "prefix": null, + "fields": [ + { + "type": "atom", + "value": "uniq_custom_one" + }, + { + "type": "atom", + "value": "uniq_custom_two" + } + ], + "unique": true, + "nulls_distinct": true, + "all_tenants?": false, + "concurrently": true, + "using": null, + "error_fields": [ + "uniq_custom_one", + "uniq_custom_two" + ] + } + ], + "has_create_action": true, + "identities": [ + { + "name": "uniq_on_upper", + "keys": [ + "UPPER(uniq_on_upper)" + ], + "where": null, + "nils_distinct?": true, + "base_filter": "type = 'sponsored'", + "index_name": "posts_uniq_on_upper_index", + "all_tenants?": false + }, + { + "name": "uniq_one_and_two", + "keys": [ + "uniq_one", + "uniq_two" + ], + "where": null, + "nils_distinct?": true, + "base_filter": "type = 'sponsored'", + "index_name": "posts_uniq_one_and_two_index", + "all_tenants?": false + }, + { + "name": "uniq_if_contains_foo", + "keys": [ + "uniq_if_contains_foo" + ], + "where": "(uniq_if_contains_foo LIKE '%foo%')", + "nils_distinct?": true, + "base_filter": "type = 'sponsored'", + "index_name": "posts_uniq_if_contains_foo_index", + "all_tenants?": false + } + ], + "custom_statements": [], + "check_constraints": [ + { + "name": "price_must_be_positive", + "check": "price > 0", + "attribute": [ + "price" + ], + "base_filter": "type = 'sponsored'" + } + ] +} \ No newline at end of file diff --git a/priv/test_repo/migrations/20240524031113_migrate_resources25.exs b/priv/test_repo/migrations/20240524031113_migrate_resources25.exs new file mode 100644 index 0000000..9c524d7 --- /dev/null +++ b/priv/test_repo/migrations/20240524031113_migrate_resources25.exs @@ -0,0 +1,32 @@ +defmodule AshPostgres.TestRepo.Migrations.MigrateResources25 do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:posts) do + add(:uniq_on_upper, :text) + end + + create( + unique_index(:posts, ["UPPER(uniq_on_upper)"], + where: "type = 'sponsored'", + name: "posts_uniq_on_upper_index" + ) + ) + end + + def down do + drop_if_exists( + unique_index(:posts, ["UPPER(uniq_on_upper)"], name: "posts_uniq_on_upper_index") + ) + + alter table(:posts) do + remove(:uniq_on_upper) + end + end +end diff --git a/priv/test_repo/migrations/20240524041750_migrate_resources26.exs b/priv/test_repo/migrations/20240524041750_migrate_resources26.exs new file mode 100644 index 0000000..a65fb87 --- /dev/null +++ b/priv/test_repo/migrations/20240524041750_migrate_resources26.exs @@ -0,0 +1,54 @@ +defmodule AshPostgres.TestRepo.Migrations.MigrateResources26 do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:posts) do + add(:uniq_if_contains_foo, :text) + end + + drop_if_exists( + unique_index(:posts, [:"UPPER(uniq_on_upper)"], name: "posts_uniq_on_upper_index") + ) + + create( + unique_index(:posts, ["UPPER(uniq_on_upper)"], + where: "type = 'sponsored'", + name: "posts_uniq_on_upper_index" + ) + ) + + create( + unique_index(:posts, [:uniq_if_contains_foo], + name: "posts_uniq_if_contains_foo_index", + where: "((uniq_if_contains_foo LIKE '%foo%')) AND (type = 'sponsored')" + ) + ) + end + + def down do + drop_if_exists( + unique_index(:posts, [:uniq_if_contains_foo], name: "posts_uniq_if_contains_foo_index") + ) + + drop_if_exists( + unique_index(:posts, ["UPPER(uniq_on_upper)"], name: "posts_uniq_on_upper_index") + ) + + create( + unique_index(:posts, [:"UPPER(uniq_on_upper)"], + where: "type = 'sponsored'", + name: "posts_uniq_on_upper_index" + ) + ) + + alter table(:posts) do + remove(:uniq_if_contains_foo) + end + end +end diff --git a/test/migration_generator_test.exs b/test/migration_generator_test.exs index 11b6758..596f2d5 100644 --- a/test/migration_generator_test.exs +++ b/test/migration_generator_test.exs @@ -155,15 +155,16 @@ defmodule AshPostgres.MigrationGeneratorTest do assert file_contents =~ ~S[add :second_title, :varchar, size: 16] # the migration creates unique_indexes based on the identities of the resource - assert file_contents =~ ~S{create unique_index(:posts, [:title], name: "posts_title_index")} + assert file_contents =~ + ~S{create unique_index(:posts, ["title"], name: "posts_title_index")} # the migration creates unique_indexes based on the identities of the resource assert file_contents =~ - ~S{create unique_index(:posts, [:title, :second_title], name: "posts_thing_index")} + ~S{create unique_index(:posts, ["second_title", "title"], name: "posts_thing_index")} # the migration creates unique_indexes using the `source` on the attributes of the identity on the resource assert file_contents =~ - ~S{create unique_index(:posts, [:title, :t_w_s], name: "posts_thing_with_source_index")} + ~S{create unique_index(:posts, ["t_w_s", "title"], name: "posts_thing_with_source_index")} end end @@ -177,11 +178,19 @@ defmodule AshPostgres.MigrationGeneratorTest do defposts do postgres do migration_types(second_title: {:varchar, 16}) + + identity_wheres_to_sql(second_title: "(second_title like '%foo%')") + schema("example") end identities do identity(:title, [:title]) + + identity :second_title, [:second_title] do + nils_distinct?(false) + where expr(contains(second_title, "foo")) + end end attributes do @@ -231,6 +240,9 @@ defmodule AshPostgres.MigrationGeneratorTest do assert file_contents =~ ~S{create index(:posts, ["id"]} + assert file_contents =~ + ~S{create unique_index(:posts, ["second_title"], name: "posts_second_title_index", prefix: "example", nulls_distinct: false, where: "(second_title like '%foo%')")} + # the migration adds the id, with its default assert file_contents =~ ~S[add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true] @@ -243,7 +255,7 @@ defmodule AshPostgres.MigrationGeneratorTest do # the migration creates unique_indexes based on the identities of the resource assert file_contents =~ - ~S{create unique_index(:posts, [:title], name: "posts_title_index", prefix: "example")} + ~S{create unique_index(:posts, ["title"], name: "posts_title_index", prefix: "example")} end end @@ -703,18 +715,18 @@ defmodule AshPostgres.MigrationGeneratorTest do file1_content = File.read!(file1) assert file1_content =~ - "create unique_index(:posts, [:title], name: \"posts_title_index\")" + "create unique_index(:posts, [\"title\"], name: \"posts_title_index\")" file2_content = File.read!(file2) assert file2_content =~ - "drop_if_exists unique_index(:posts, [:title], name: \"posts_title_index\")" + "drop_if_exists unique_index(:posts, [\"title\"], name: \"posts_title_index\")" assert file2_content =~ - "create unique_index(:posts, [:name], name: \"posts_unique_name_index\")" + "create unique_index(:posts, [\"name\"], name: \"posts_unique_name_index\")" assert file2_content =~ - "create unique_index(:posts, [:title], name: \"posts_unique_title_index\")" + "create unique_index(:posts, [\"title\"], name: \"posts_unique_title_index\")" end test "when an attribute exists only on some of the resources that use the same table, it isn't marked as null: false" do @@ -1073,7 +1085,7 @@ defmodule AshPostgres.MigrationGeneratorTest do assert [file] = Path.wildcard("test_migration_path/**/*_migrate_resources*.exs") assert File.read!(file) =~ - ~S{create unique_index(:users, [:name], name: "users_unique_name_index")} + ~S{create unique_index(:users, ["name"], name: "users_unique_name_index")} end test "when modified, the foreign key is dropped before modification" do diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 511857f..467b284 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -60,6 +60,9 @@ defmodule AshPostgres.Test.Post do repo(AshPostgres.TestRepo) base_filter_sql("type = 'sponsored'") + calculations_to_sql(upper_thing: "UPPER(uniq_on_upper)") + identity_wheres_to_sql(uniq_if_contains_foo: "(uniq_if_contains_foo LIKE '%foo%')") + check_constraints do check_constraint(:price, "price_must_be_positive", message: "yo, bad price", @@ -186,6 +189,11 @@ defmodule AshPostgres.Test.Post do identities do identity(:uniq_one_and_two, [:uniq_one, :uniq_two]) + identity(:uniq_on_upper, [:upper_thing]) + + identity(:uniq_if_contains_foo, [:uniq_if_contains_foo]) do + where expr(contains(title, "foo")) + end end attributes do @@ -219,6 +227,8 @@ defmodule AshPostgres.Test.Post do attribute(:uniq_two, :string, public?: true) attribute(:uniq_custom_one, :string, public?: true) attribute(:uniq_custom_two, :string, public?: true) + attribute(:uniq_on_upper, :string, public?: true) + attribute(:uniq_if_contains_foo, :string, public?: true) attribute :list_containing_nils, {:array, :string} do public?(true) @@ -335,6 +345,8 @@ defmodule AshPostgres.Test.Post do end calculations do + calculate(:upper_thing, :string, expr(fragment("UPPER(?)", uniq_on_upper))) + calculate( :author_has_post_with_follower_named_fred, :boolean,