diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 70b9ff57..51cbc7b9 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -865,7 +865,35 @@ defmodule AshPostgres.DataLayer do # value Postgres can't round-trip directly. `Ash.Type.Range`'s value is an # `%Ash.Range{}`, which Postgrex can neither encode nor produce — it needs a # `%Postgrex.Range{}`. Routing the schema field through `AshPostgres.Type.Range` - # makes both `insert_all` (dump) and `repo.all` (load) go through our type. + # makes both `insert_all` (dump) and `repo.all` (load) go through our type + + def attribute_ecto_type(resource, %{type: Ash.Type.DateTime, constraints: constraints}) do + repo = AshPostgres.DataLayer.Info.repo(resource, :mutate) + + if repo do + case Code.ensure_compiled(repo) do + {:module, _} -> + if function_exported?(repo, :use_timestamptz?, 0) && + repo.use_timestamptz?() do + case Keyword.get(constraints, :precision) do + :microsecond -> + Ash.Type.ecto_type(AshPostgres.TimestamptzUsec) + + _ -> + Ash.Type.ecto_type(AshPostgres.Timestamptz) + end + else + nil + end + + _ -> + nil + end + else + nil + end + end + def attribute_ecto_type(_resource, %{type: Ash.Type.Range}) do Ash.Type.ecto_type(AshPostgres.Type.Range) end diff --git a/lib/migration_generator/migration_generator.ex b/lib/migration_generator/migration_generator.ex index 6acafae6..2a828d62 100644 --- a/lib/migration_generator/migration_generator.ex +++ b/lib/migration_generator/migration_generator.ex @@ -3915,10 +3915,26 @@ defmodule AshPostgres.MigrationGenerator do |> Enum.map(fn attribute -> default = default(attribute, resource, repo) + migration_type_override = + AshPostgres.DataLayer.Info.migration_types(resource)[attribute.name] + type = - AshPostgres.DataLayer.Info.migration_types(resource)[attribute.name] || + migration_type_override || migration_type(attribute.type, attribute.constraints) + type = + if is_nil(migration_type_override) && + function_exported?(repo, :use_timestamptz?, 0) && + repo.use_timestamptz?() do + case type do + :utc_datetime -> :timestamptz + :utc_datetime_usec -> :"timestamptz(6)" + type -> type + end + else + type + end + type = if function_exported?(repo, :override_migration_type, 1) do repo.override_migration_type(type) diff --git a/lib/repo.ex b/lib/repo.ex index e3c75f1e..7f49663f 100644 --- a/lib/repo.ex +++ b/lib/repo.ex @@ -135,6 +135,10 @@ defmodule AshPostgres.Repo do @doc "Allows overriding a given migration type for *all* fields, for example if you wanted to always use :timestamptz for :utc_datetime fields" @callback override_migration_type(atom) :: atom + + @doc "Whether or not to use PostgreSQL `timestamptz` for `Ash.Type.DateTime` fields." + @callback use_timestamptz?() :: boolean + @doc "Whether or not to use the built-in `uuidv7` function (as opposed to the Ash `uuid_generate_v7` function) for `UUIDv7` fields." @callback use_builtin_uuidv7_function? :: boolean @doc "Should the repo should be created by `mix ash_postgres.create`?" @@ -185,6 +189,11 @@ defmodule AshPostgres.Repo do def default_prefix, do: "public" def override_migration_type(type), do: type + @doc """ + Whether to use 'timestamptz' for data storage, defaults to false + """ + def use_timestamptz?, do: false + def use_builtin_uuidv7_function? do %Version{major: major} = min_pg_version() major >= 18 @@ -363,6 +372,7 @@ defmodule AshPostgres.Repo do migrations_path: 0, default_prefix: 0, override_migration_type: 1, + use_timestamptz?: 0, create?: 0, drop?: 0, disable_atomic_actions?: 0, diff --git a/test/migration_generator_test.exs b/test/migration_generator_test.exs index 6d56082e..2e224638 100644 --- a/test/migration_generator_test.exs +++ b/test/migration_generator_test.exs @@ -269,6 +269,146 @@ defmodule AshPostgres.MigrationGeneratorTest do end end + defmodule TimestamptzToggleRepo do + use AshPostgres.Repo, + otp_app: :ash_postgres + + def use_timestamptz?, do: true + + def min_pg_version do + %Version{major: 16, minor: 0, patch: 0} + end + end + + defmodule TimestamptzPost do + use Ash.Resource, + domain: nil, + data_layer: AshPostgres.DataLayer + + postgres do + table "timestamptz_posts" + repo TimestamptzToggleRepo + migration_types(explicit_type: :utc_datetime) + end + + attributes do + uuid_primary_key(:id) + attribute(:starts_at, :datetime, public?: true) + attribute(:explicit_type, :datetime, public?: true) + end + + actions do + create :create do + accept([:starts_at]) + end + + read :read do + primary?(true) + end + end + end + + defmodule TimestamptzDomain do + use Ash.Domain + + resources do + resource(TimestamptzPost) + end + end + + describe "timestamptz datetime toggle" do + setup %{snapshot_path: snapshot_path, migration_path: migration_path} do + {:ok, _pid} = + TimestamptzToggleRepo.start_link( + username: "postgres", + password: "postgres", + database: "ash_postgres_test", + hostname: "localhost", + pool: Ecto.Adapters.SQL.Sandbox, + types: AshPostgres.Test.PostgrexTypes + ) + + :ok = Ecto.Adapters.SQL.Sandbox.checkout(TimestamptzToggleRepo) + + Ecto.Adapters.SQL.Sandbox.mode( + TimestamptzToggleRepo, + {:shared, self()} + ) + + Ecto.Adapters.SQL.query!( + TimestamptzToggleRepo, + """ + CREATE TABLE timestamptz_posts ( + id uuid PRIMARY KEY, + starts_at timestamptz, + explicit_type timestamp without time zone + ) + """ + ) + + assert AshPostgres.SqlImplementation.storage_type( + TimestamptzPost, + :starts_at + ) == nil + + AshPostgres.MigrationGenerator.generate( + TimestamptzDomain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + {:ok, timestamptz_post: TimestamptzPost} + end + + test "uses timestamptz for :datetime when enabled", %{ + migration_path: migration_path + } do + files = + Path.wildcard("#{migration_path}/**/*_migrate_resources*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + + assert [file] = files + + file_contents = File.read!(file) + + assert file_contents =~ ~S[add :starts_at, :timestamptz] + assert file_contents =~ ~S[add :explicit_type, :utc_datetime] + end + + test "uses timestamptz Ecto type when enabled", %{timestamptz_post: resource} do + assert resource.__schema__(:type, :starts_at) == + {:parameterized, + {AshPostgres.Timestamptz.EctoType, + [precision: :second, cast_dates_as: :start_of_day, timezone: :utc]}} + end + + test "preserves datetime across database timezone changes", %{ + timestamptz_post: resource + } do + datetime = ~U[2026-01-15 12:00:00Z] + + post = + resource + |> Ash.Changeset.for_create(:create, %{starts_at: datetime}, domain: TimestamptzDomain) + |> Ash.create!() + + {:ok, _result} = + Ecto.Adapters.SQL.query( + TimestamptzToggleRepo, + "SET timezone = 'America/Los_Angeles'" + ) + + loaded = + resource + |> Ash.get!(post.id, domain: TimestamptzDomain) + + assert DateTime.compare(loaded.starts_at, datetime) == :eq + end + end + describe "get_operations_from_snapshots" do test "explicit fk attribute order does not change create table emission" do # This also reproduces if a non-identity attribute like :note appears between