Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion lib/data_layer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion lib/migration_generator/migration_generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions lib/repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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`?"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
140 changes: 140 additions & 0 deletions test/migration_generator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down