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/4cannot express this:ON CONFLICTonly 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 sameunnest(...)source throughNOT 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 CASCADEdisappears with no error — so:on_deleteis explicit about it::restrict(default) readspg_constraintand refuses to run when a referencing foreign key isCASCADE/SET NULL/SET DEFAULT;:skip_referenceddeletes 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);:cascadeopts in;:nothingskips deletion.NO ACTION/RESTRICTchildren need no check — Postgres raises and the transaction rolls the whole sync back.lock: trueserializes 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: trueto override), and:on_conflictdefaults to replacing exactly the columns the source carries — not Ecto's:replace_all. That matters for row identity::replace_allexpands to__schema__(:updatable_fields), primary key included, and for a column the INSERT does not listEXCLUDEDholds the column's default — NULL, or the next sequence value for a serial. Syncing on a natural key with:replace_alltherefore rendersSET "id" = EXCLUDED."id"and renumbers every row it touches, repointing anything that referenced them. A caller-supplied:on_conflictwhose 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 andON DELETEaction, as used bysync_all/4.
Fixed
sync_all/4now 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
unnestaliases 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 reachedmod.type(params)and any parameterized per-row column raisedunknown type ... — pass it via :types. Placeholders were unaffected (they take the cast from the Ecto type directly), which is why anEcto.Enumworked as a placeholder but not as an unnest column. Schemas carrying their own parameterized types —Ecto.Enum, customEcto.ParameterizedTypes, Ash resources (every attribute is parameterized) — now infer without a:typesoverride, jsonb detection included.Both tuple shapes are matched, so the declared
ecto ~> 3.10continues 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/4now derives the Postgrex:cache_statementname from the table and the unnest arity (the number of array columns), e.g.ecto_unnest_all_events_2. Ecto's default keysinsert_allon the table alone (ecto_insert_all_events), butunnestrenders 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). Passcache_statement: "name"to override, orcache_statement: nilto restore Ecto's default. (Arity does not separate same-arity/different-column shapes — give those an explicit:cache_statement.) :require_all_fieldscompleteness check. Withrequire_all_fields: trueevery 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 andtimestamps()fields are exempt. Schema sources only (a binary source has no field list and raises). Defaults toconfig :ecto_unnest, :require_all_fields(so it can be a test-only safety net), elsefalse; 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-Dtext[]of pre-encoded JSON with a::jsonbcast in theSELECTprojection (instead of a::jsonb[]param, whichunnestflattened and Postgrex double-encoded). Schema sources auto-detect:map/{:map, _}/{:array, :map}; binary sources opt in withtypes: %{col: :jsonb}or the new:jsonoption. Raw terms are encoded with the configured:postgrex:json_library; already-encoded strings pass through. - Full
Ecto.Queryas:on_conflict. Enables a conditionalON CONFLICT ... DO UPDATE SET ... WHERE <predicate>(andORDER BY), matchingEcto.Repo.insert_all/3. :typesaccepts atoms as well as strings.config :ecto_unnest, :allowed_types— an explicit allow-list of custom PG types permitted in a:typesoverride.
Security
:typesoverrides 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 likeint4, a modifier) must be listed inconfig :ecto_unnest, :allowed_typesor the call raises. Apps using:typeswith 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 fromtype(^value, type)in the projection, not theunnestparam. Integer-backedEcto.Enum, date and uuid placeholders now work on schema sources with no:typesentry. - 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::typecast on the parameter.
Notes
- Binary-source column alignment was confirmed correct on execution: both the
executed
Repo.insert_all/3andto_sql/3derive theINSERTcolumn list from the same name-orderedSELECT, and the projection referencesunnestcolumns by name — so values land in their named column regardless of physical column order. A regression test guards this.