0.3.2 — 2026-09-25

Added

  • sync_all/4 — make a table match a file. One call upserts every row of the source and removes the rows the source no longer has, in a single transaction:

    EctoUnnest.sync_all(Repo, Setting, %{key: ["a", "b"], value: ["1", "2"]})
    #=> %{upserted: 2, deleted: 1, soft_deleted: 0,
    #=>   deleted_keys: [%{key: "c"}], skipped_keys: [], on_delete: :restrict}

    insert_all/4 cannot express this: ON CONFLICT only ever sees the rows you sent, so a row deleted from the file is a row Postgres never hears about. The delete reads its keys back out of the same unnest(...) source through NOT EXISTS, so both statements keep SQL text independent of the row count, and composite keys need no special casing.

    Deletion is the one part of a sync that can destroy data the source never described — a child row reached through ON DELETE CASCADE disappears with no error — so :on_delete is explicit about it: :restrict (default) reads pg_constraint and refuses to run when a referencing foreign key is CASCADE/SET NULL/SET DEFAULT; :skip_referenced deletes only the absent rows nothing references and reports the rest in :skipped_keys; {:soft, field} tombstones instead of deleting (and revives a row that reappears in the source); :cascade opts in; :nothing skips deletion. NO ACTION/RESTRICT children need no check — Postgres raises and the transaction rolls the whole sync back.

    lock: true serializes concurrent syncs of the table with a transaction-scoped advisory lock keyed on its oid (off by default, since it blocks; pass an integer to choose the key). Two syncs running at once would otherwise interleave — both upsert, then both delete — and the loser's rows would be removed by the winner's delete before its own upsert became visible.

    An empty source raises rather than emptying the table (allow_empty: true to override), and :on_conflict defaults to replacing exactly the columns the source carries — not Ecto's :replace_all. That matters for row identity: :replace_all expands to __schema__(:updatable_fields), primary key included, and for a column the INSERT does not list EXCLUDED holds the column's default — NULL, or the next sequence value for a serial. Syncing on a natural key with :replace_all therefore renders SET "id" = EXCLUDED."id" and renumbers every row it touches, repointing anything that referenced them. A caller-supplied :on_conflict whose replace list names a column the source does not provide now raises instead.

  • EctoUnnest.Constraints.referencing/3 — the foreign keys pointing at a table, with their column pairing and ON DELETE action, as used by sync_all/4.

Fixed

  • sync_all/4 now rejects duplicate source keys before opening a transaction, with a clear error for both single-column and composite keys.
  • SQL identifiers used by constraint inspection, advisory locks, and unnest aliases now escape embedded double quotes safely.

Security

  • Documented that column names, table names, prefixes, conflict targets, and type overrides must come from trusted application code or configuration. User data should be passed as parameterized row values or placeholders.

0.3.1 — 2026-08-08

Fixed

  • Type inference for parameterized types. {:parameterized, {mod, params}} (the shape Ecto 3.12+ stores in __schema__(:type, field)) was matched with the pre-3.12 three-element pattern, so it never reached mod.type(params) and any parameterized per-row column raised unknown type ... — pass it via :types. Placeholders were unaffected (they take the cast from the Ecto type directly), which is why an Ecto.Enum worked as a placeholder but not as an unnest column. Schemas carrying their own parameterized types — Ecto.Enum, custom Ecto.ParameterizedTypes, Ash resources (every attribute is parameterized) — now infer without a :types override, jsonb detection included.

    Both tuple shapes are matched, so the declared ecto ~> 3.10 continues to hold — inference behaves the same on 3.10/3.11 as on 3.12+.

0.3.0 — 2026-06-30

Added

  • Per-shape prepared-statement cache names. insert_all/4 now derives the Postgrex :cache_statement name from the table and the unnest arity (the number of array columns), e.g. ecto_unnest_all_events_2. Ecto's default keys insert_all on the table alone (ecto_insert_all_events), but unnest renders different SQL per column shape, so distinct shapes for one table would otherwise collide on one cache slot and force a re-prepare on every shape change. The row count never enters the name (the SQL is constant across it). Pass cache_statement: "name" to override, or cache_statement: nil to restore Ecto's default. (Arity does not separate same-arity/different-column shapes — give those an explicit :cache_statement.)
  • :require_all_fields completeness check. With require_all_fields: true every schema field must appear in the columns map or in :placeholders, else the call raises listing what is missing — catching a forgotten column that would otherwise silently take its DB default. The autogenerated primary key and timestamps() fields are exempt. Schema sources only (a binary source has no field list and raises). Defaults to config :ecto_unnest, :require_all_fields (so it can be a test-only safety net), else false; an explicit per-call option always wins.

0.2.0 — 2026-06-24

Added

  • Per-row JSON columns (jsonb / {:array, :map}). A column whose per-row value is itself JSON is now shipped as a 1-D text[] of pre-encoded JSON with a ::jsonb cast in the SELECT projection (instead of a ::jsonb[] param, which unnest flattened and Postgrex double-encoded). Schema sources auto-detect :map / {:map, _} / {:array, :map}; binary sources opt in with types: %{col: :jsonb} or the new :json option. Raw terms are encoded with the configured :postgrex :json_library; already-encoded strings pass through.
  • Full Ecto.Query as :on_conflict. Enables a conditional ON CONFLICT ... DO UPDATE SET ... WHERE <predicate> (and ORDER BY), matching Ecto.Repo.insert_all/3.
  • :types accepts atoms as well as strings.
  • config :ecto_unnest, :allowed_types — an explicit allow-list of custom PG types permitted in a :types override.

Security

  • :types overrides are now fail-closed. The value is rendered into the SQL cast verbatim, so it is validated: Ecto's default PG types (bigint, text, jsonb, timestamptz, …) are always allowed, but any other spelling (a domain, an alias like int4, a modifier) must be listed in config :ecto_unnest, :allowed_types or the call raises. Apps using :types with non-default types (including binary sources, which require :types) must add this config.

Fixed

  • Placeholders no longer require a pg_type. resolve_pg_type! is skipped for placeholder (:scalar) columns, since their cast comes from type(^value, type) in the projection, not the unnest param. Integer-backed Ecto.Enum, date and uuid placeholders now work on schema sources with no :types entry.
  • Binary-source non-string placeholders can be cast via :types (e.g. types: %{created_at: :timestamptz}), including custom PG types such as a domain :kafka_topic_name, rendered as a raw ::type cast on the parameter.

Notes

  • Binary-source column alignment was confirmed correct on execution: both the executed Repo.insert_all/3 and to_sql/3 derive the INSERT column list from the same name-ordered SELECT, and the projection references unnest columns by name — so values land in their named column regardless of physical column order. A regression test guards this.