From 08c5279acb2158bd50d36e0bf974e95fa5d23eca Mon Sep 17 00:00:00 2001 From: Marc Nickert Date: Wed, 29 Jul 2026 14:22:52 +0200 Subject: [PATCH] test: cover bulk upsert dropping records when select excludes identity keys A bulk upsert pairs each returned row back up with its changeset by the upsert identity's keys, but the `RETURNING` list is built from the action's `action_select` alone. When the select doesn't include those keys, every row reads back with `nil` for each one, nothing correlates, and every record is dropped from the result - while the rows are still written. The caller sees `%Ash.BulkResult{status: :success, errors: [], records: []}` for a batch that inserted or updated fine. These two tests currently FAIL on main. They are untagged, so they cover both the `INSERT ... ON CONFLICT` path and the PostgreSQL 17+ `MERGE` path. `:upsert_with_no_filter` is used because it declares no changes and no notifiers, which makes its `action_select` just the primary key - an explicit `select` therefore can't widen it to include the identity key. --- test/bulk_create_test.exs | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/bulk_create_test.exs b/test/bulk_create_test.exs index 883b2af8..933e39e4 100644 --- a/test/bulk_create_test.exs +++ b/test/bulk_create_test.exs @@ -323,6 +323,64 @@ defmodule AshPostgres.BulkCreateTest do end) end + # Bulk upserts correlate the returned rows back to their changesets by the upsert + # identity's keys, so those keys have to be read back even when the action's select + # doesn't ask for them - otherwise no row correlates and every record is silently + # dropped from the result while still being written to the database. + # + # `:upsert_with_no_filter` declares no changes or notifiers, so its `action_select` + # is just the primary key; an explicit `select` therefore can't widen it to include + # `:uniq_if_contains_foo`. `select: []` is what `Ash.Reactor`'s `bulk_create` step + # passes. + test "bulk upsert returns inserted records when select excludes the identity keys" do + assert %Ash.BulkResult{status: :success, records: records} = + Ash.bulk_create( + [ + %{title: "fredfoo", uniq_if_contains_foo: "1foo", price: 10}, + %{title: "georgefoo", uniq_if_contains_foo: "2foo", price: 20} + ], + Post, + :upsert_with_no_filter, + return_records?: true, + return_errors?: true, + select: [] + ) + + assert length(records) == 2 + assert Enum.all?(records, & &1.id) + + assert [10, 20] == Post |> Ash.read!() |> Enum.map(& &1.price) |> Enum.sort() + end + + test "bulk upsert returns updated records when select excludes the identity keys" do + Ash.bulk_create!( + [ + %{title: "fredfoo", uniq_if_contains_foo: "1foo", price: 10}, + %{title: "georgefoo", uniq_if_contains_foo: "2foo", price: 20} + ], + Post, + :create + ) + + assert %Ash.BulkResult{status: :success, records: records} = + Ash.bulk_create( + [ + %{title: "fredfoo", uniq_if_contains_foo: "1foo", price: 1000}, + %{title: "georgefoo", uniq_if_contains_foo: "2foo", price: 20_000} + ], + Post, + :upsert_with_no_filter, + return_records?: true, + return_errors?: true, + select: [:id] + ) + + assert length(records) == 2 + assert Enum.all?(records, & &1.id) + + assert [1000, 20_000] == Post |> Ash.read!() |> Enum.map(& &1.price) |> Enum.sort() + end + test "bulk upsert returns skipped records with return_skipped_upsert?" do assert [ {:ok, %{title: "fredfoo", uniq_if_contains_foo: "1foo", price: 10}},