Writes
The shortcuts that write: insert(), upsert(), update(), deletes() and truncate(). For each, its parameters, every option with the SQL it adds and its effect on the type, and what it throws.
What every write shares:
- It returns a
SQLFragment:.run(pool)sends it,.compile()gives itstextandvalueswithout sending anything. - A value is sent as a parameter (
$1), never written into the SQL; asqlfragment is SQL. - Rows come back through
to_jsonb(), as a table'sJSONSelectable: atimestamptzis a string (The catalog). - An error from Postgres rejects
.run()with aDatabaseError(isDatabaseError()). The errors below are thrown by pgcast as it builds the query, before anything is sent.
insert()
ts
db.insert(table, values, options?)Insert one row or several, and return them as Postgres stored them, defaults and serials filled in.
table: a table of the schema.values: anInsertable, or an array of them. Asqlfragment is written as SQL (db.sql`now()`),db.DefaultasDEFAULT; anundefinedvalue isNULL(Anundefinedvalue).raw(),cols()andvals()are no values here: they throw while compiling, before any query (raw()gives the message), and pgcast's type refuses them, where Zapatos 6.6.1's took them. For SQL, write asqlfragment; in an array or ajsonbobject, they are part of the value, sent as JSON.db.parent()throws "The 'parent' table alias has no meaning here": anINSERThas no parent query. The type refuses it.- A column typed
any, as azapatos/customplaceholder is, passes the type, not the run time. Zapatos 6.6.1 sent each as one parameter, as JSON:{ tagline: db.raw("'x'") }stored{"value":"'x'"},db.parent('id'){"value":"id"}(#259).
options:returningandextras(Options).
TSYou write Open in playground
ts
const films = await db.insert('films', [{
studio_id: 1001,
title: 'The Tidewater Ledger',
release_year: 2031,
runtime_minutes: 117,
tagline: 'Every debt comes in with the tide.',
}, {
studio_id: 1002,
title: 'Moths over Harrowgate',
release_year: 2031,
runtime_minutes: 95,
}]).run(pool);SQLPostgres runs
sql
INSERT INTO "films" ("release_year", "runtime_minutes", "studio_id", "tagline", "title")
VALUES ($1, $2, $3, $4, $5), ($6, $7, $8, DEFAULT, $9)
RETURNING to_jsonb("films".*) AS result
values [2031, 117, 1001, "Every debt comes in with the tide.", "The Tidewater Ledger", 2031, 95, 1002, "Moths over Harrowgate"]
::TypeScript knows
ts
const films: films.JSONSelectable[]
// that is
const films: {
genres: string[];
id: number;
release_year: number;
runtime_minutes: number;
studio_id: number;
tagline: string | null;
title: string;
}[]
- One row gives one result; an array gives an array, in the order Postgres returns them, in practice the order given.
- The columns are the keys of every row; a key missing from a row is
DEFAULTin that row, astaglineabove. - An empty array is a no-op:
.run(pool)resolves with[]and sends nothing, unless forced with.run(pool, true). Its SQL,INSERT INTO "films" SELECT null WHERE false, is marked as a no-op. - Postgres takes at most 65,535 parameters in a query: split a bigger insert (Batches).
Options
returning and extras are the same for upsert(), update() and deletes() (ReturningOptionsForTable).
TSYou write Open in playground
ts
const film = await db.insert('films', {
studio_id: 1000,
title: 'Winter at Fennick Lock',
release_year: 2032,
runtime_minutes: 108,
}, {
returning: ['id'],
extras: {
name: 'title',
shouted: db.sql<s.films.SQL, string>`upper(${'title'})`,
},
}).run(pool);SQLPostgres runs
sql
INSERT INTO "films" ("release_year", "runtime_minutes", "studio_id", "title")
VALUES ($1, $2, $3, $4)
RETURNING jsonb_build_object($5::text, "id") ||
jsonb_build_object($6::text, "title", $7::text, upper("title")) AS result
values [2032, 108, 1000, "Winter at Fennick Lock", "id", "name", "shouted"]
::TypeScript knows
ts
const film: db.ReturningTypeForTable<"films", "id"[], {
name: "title";
shouted: db.SQLFragment<string, never>;
}>
// that is
const film: {
id: number;
name: string;
shouted: string;
}
| Option | SQL it adds | Effect on the type |
|---|---|---|
| none | RETURNING to_jsonb("films".*) AS result | films.JSONSelectable |
returning: ['id'] | jsonb_build_object($5::text, "id") in place of to_jsonb("films".*) | Pick<films.JSONSelectable, 'id'> |
extras: { name: 'title', shouted: … }, as above | || jsonb_build_object($6::text, "title", $7::text, upper("title")) | adds name, of the column's type, and shouted, of the fragment's RunResult: string, from db.sql<s.films.SQL, string> |
- A
readonlyarray inreturning(['id'] as const) gives the same SQL and the same type as a mutable one (columns). - Both need
strictNullChecksfor their types.
upsert()
ts
db.upsert(table, values, conflictTarget, options?)INSERT … ON CONFLICT … DO UPDATE: insert each row, or update the existing row it conflicts with.
table,values: as forinsert();raw(),cols(),vals()andparent()throw there too, on a conflicting row or a new one.conflictTarget: the arbiter of the conflict: a column, an array of columns, or the name of a unique or primary-key constraint wrapped inconstraint().options:updateColumns,noNullUpdateColumns,updateValuesandreportAction(Options);returningandextras, as forinsert().
TSYou write Open in playground
ts
const rating = await db.upsert('ratings', {
film_id: 1002,
stars: 4,
}, ['film_id', 'stars'], {
updateValues: { votes: db.sql`${'ratings'}.${'votes'} + 1` },
}).run(pool);SQLPostgres runs
sql
INSERT INTO "ratings" ("film_id", "stars")
VALUES ($1, $2)
ON CONFLICT ("film_id", "stars") DO UPDATE SET ("film_id", "stars", "votes") = ROW(EXCLUDED."film_id", EXCLUDED."stars", "ratings"."votes" + 1)
RETURNING to_jsonb("ratings".*) ||
jsonb_build_object('$action', CASE xmax WHEN 0 THEN 'INSERT' ELSE 'UPDATE' END) AS result
values [1002, 4]
::TypeScript knows
ts
const rating: db.UpsertReturnableForTable<"ratings", db.ColumnsOption<"ratings">, db.ExtrasOption<"ratings">, "suppress" | undefined>
// that is
const rating: {
film_id: number;
stars: number;
votes: number;
$action: "INSERT" | "UPDATE";
}
- The update sets every column inserted, from
EXCLUDED, and each key ofupdateValues. - Each result has
$action,'INSERT'or'UPDATE'(UpsertAction). - One row gives one result; an array gives an array, without the rows that do nothing (
doNothing). - An empty array is a no-op, as for
insert().
Options
Each SQL replaces the DO UPDATE SET … of the plain upsert, on ratings (on films for noNullUpdateColumns), or adds to it:
| Option | SQL it adds | Effect on the type |
|---|---|---|
| none | DO UPDATE SET ("film_id", "stars", "votes") = ROW(EXCLUDED."film_id", EXCLUDED."stars", EXCLUDED."votes") | ratings.JSONSelectable & UpsertAction |
updateColumns: 'votes' | DO UPDATE SET ("votes") = ROW(EXCLUDED."votes") | none |
updateColumns: db.doNothing | DO NOTHING | | undefined, for one row |
noNullUpdateColumns: 'tagline' | CASE WHEN EXCLUDED."tagline" IS NULL THEN "films"."tagline" ELSE EXCLUDED."tagline" END in place of EXCLUDED."tagline" | none |
updateValues: { votes: 10 } | ROW(EXCLUDED."film_id", EXCLUDED."stars", $3): $3 in place of EXCLUDED."votes" | none |
updateValues: { votes: db.sql`${'ratings'}.${'votes'} + 1` } | "ratings"."votes" + 1 in place of EXCLUDED."votes" | none |
reportAction: 'suppress' | no || jsonb_build_object('$action', CASE xmax WHEN 0 THEN 'INSERT' ELSE 'UPDATE' END) | no UpsertAction |
updateColumnstakes a column or an array;noNullUpdateColumnsa column, an array, ordb.allfor every column. Batches has a panel ofupdateColumns: a booking keeps its time.updateValuesis one set of values for every row updated. Its keys are always updated, even whenupdateColumnsleaves them out, or isdoNothing(then the SQL isDO UPDATE SET, notDO NOTHING).- In
updateValues, a plain value is a parameter. A value that holds a fragment,param(),raw(),cols(),vals(),parent(),Defaultorself, at any depth of its arrays and plain objects, is compiled as in asqltemplate. - To read a column there, name the table for the existing row,
${'ratings'}.${'votes'}, orEXCLUDEDfor the new one,db.sql`EXCLUDED.${'votes'}`. - There,
db.self, or a fragment or condition that uses it (db.sql`${db.self} + 1`,conditions.add()), throws "The 'self' column alias has no meaning here", as in Zapatos 6.6.1;db.parent(), alone or in a fragment, throws "The 'parent' table alias has no meaning here".
Security
Zapatos 6.6.1 wrote a plain string in updateValues as an identifier: { tagline: 'happy' } put "happy" in the ROW(…), and a string from a user could read other columns or tables.
pgcast sends it as a parameter, $3 in its place, as its type and update() always said. To read a column there, write a fragment, as in the table (Security).
With noNullUpdateColumns, a NULL leaves the existing value. Film 1002 comes back from a feed with no tagline, and keeps its own:
TSYou write Open in playground
ts
const film = await db.upsert('films', {
id: 1002,
studio_id: 1000,
title: "The Cartographer's Silence",
release_year: 2008,
runtime_minutes: 131,
tagline: null,
}, 'id', { noNullUpdateColumns: 'tagline' }).run(pool);SQLPostgres runs
sql
INSERT INTO "films" ("id", "release_year", "runtime_minutes", "studio_id", "tagline", "title")
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT ("id") DO UPDATE SET ("id", "studio_id", "title", "release_year", "runtime_minutes", "tagline") = ROW(EXCLUDED."id", EXCLUDED."studio_id", EXCLUDED."title", EXCLUDED."release_year", EXCLUDED."runtime_minutes", CASE WHEN EXCLUDED."tagline" IS NULL THEN "films"."tagline" ELSE EXCLUDED."tagline" END)
RETURNING to_jsonb("films".*) ||
jsonb_build_object('$action', CASE xmax WHEN 0 THEN 'INSERT' ELSE 'UPDATE' END) AS result
values [1002, 2008, 131, 1000, null, "The Cartographer's Silence"]
::TypeScript knows
ts
const film: db.UpsertReturnableForTable<"films", db.ColumnsOption<"films">, db.ExtrasOption<"films">, "suppress" | undefined>
// that is
const film: {
genres: string[];
id: number;
release_year: number;
runtime_minutes: number;
studio_id: number;
tagline: string | null;
title: string;
$action: "INSERT" | "UPDATE";
}
- The update still sets every column inserted,
"id"included; only"tagline"keeps the existing value when the new one isNULL. filmis the updated row:$actionis'UPDATE', andtaglineis still'Every map leaves something out.'.
With reportAction: 'suppress', the result has no $action:
TSYou write Open in playground
ts
const rating = await db.upsert('ratings', {
film_id: 1002,
stars: 4,
votes: 2,
}, ['film_id', 'stars'], { reportAction: 'suppress' }).run(pool);SQLPostgres runs
sql
INSERT INTO "ratings" ("film_id", "stars", "votes")
VALUES ($1, $2, $3)
ON CONFLICT ("film_id", "stars") DO UPDATE SET ("film_id", "stars", "votes") = ROW(EXCLUDED."film_id", EXCLUDED."stars", EXCLUDED."votes")
RETURNING to_jsonb("ratings".*) AS result
values [1002, 4, 2]
::TypeScript knows
ts
const rating: ratings.JSONSelectable
// that is
const rating: {
film_id: number;
stars: number;
votes: number;
}
With strictNullChecks off, the type keeps $action, which is then undefined.
Rows with different keys
In several rows without updateColumns, a key missing from one row is inserted as DEFAULT, then set from EXCLUDED on conflict: that row's existing value is lost.
[{ film_id: 1002, stars: 5, votes: 3 }, { film_id: 1002, stars: 4 }]sends($4, $5, DEFAULT), so an existing count of 4-star votes goes back to 1.- It runs, as in Zapatos 6.6.1, and warns once per process:
PGCAST_DEP001. - A key of
updateValues, or a column of an array or stringconflictTarget, does not count: new rows without their serialid, upserted on'id', neither warn nor throw. Withconstraint(), every key counts. - Rows with the same keys, one row, and any
updateColumnsnever warn.
Opt-in
With upsertRequiresSameKeys: true (Config), such an upsert throws before any query: "rows have different keys: pass updateColumns, or give every row the same keys". Without it, pgcast warns as PGCAST_DEP001; it becomes the default in 7.0 (Versions).
constraint()
ts
db.constraint(name)Wrap the name of a unique or primary-key constraint of the table, for the conflictTarget of upsert(): ON CONFLICT ON CONSTRAINT "ratings_pkey", in place of ON CONFLICT ("film_id", "stars"). The names come from the schema, so one the table doesn't have fails to compile:
ts
db.upsert('ratings', { film_id: 1002, stars: 4 }, db.constraint('ratings_pkey'));
db.upsert('ratings', { film_id: 1002, stars: 4 }, db.constraint('ratings_stars_key'));- The type takes every unique index of the table, and Postgres only a constraint. An index made with
CREATE UNIQUE INDEX, which is no constraint, type-checks, and Postgres refuses it: 42704,constraint "…" for table "…" does not exist. - For such an index, pass its columns as the
conflictTarget:'code'for a unique index oncode.
The type does not check that a constraint belongs to the upsert's table, and Postgres refuses another table's: 42704. Only inside the call does constraint() take the table's names, as above. Held in a variable, const k = db.constraint('tags_pkey') takes any table's name, so db.upsert('ratings', …, k) compiles. Give the table, db.constraint<'ratings'>('ratings_pkey'), and the type checks the name against it:
ts
const k = db.constraint('tags_pkey');
db.upsert('ratings', { film_id: 1002, stars: 4 }, k); // compiles, then 42704
// @ts-expect-error tags_pkey is not a constraint of ratings
db.constraint<'ratings'>('tags_pkey');Constraint
The class that constraint() returns, its constraint name in value: new db.Constraint<'ratings'>('ratings_pkey') is the same.
doNothing
An empty array, for the updateColumns of upsert(): a row that conflicts is left as it is, and returns nothing.
TSYou write Open in playground
ts
const tag = await db.upsert('tags', {
film_id: 1003,
tag: 'coastal',
}, ['film_id', 'tag'], {
updateColumns: db.doNothing,
}).run(pool);SQLPostgres runs
sql
INSERT INTO "tags" ("film_id", "tag")
VALUES ($1, $2)
ON CONFLICT ("film_id", "tag") DO NOTHING
RETURNING to_jsonb("tags".*) ||
jsonb_build_object('$action', CASE xmax WHEN 0 THEN 'INSERT' ELSE 'UPDATE' END) AS result
values [1003, "coastal"]
::TypeScript knows
ts
const tag: db.UpsertReturnableForTable<"tags", db.ColumnsOption<"tags">, db.ExtrasOption<"tags">, "suppress" | undefined> | undefined
// that is
const tag: {
film_id: number;
tag: string;
$action: "INSERT" | "UPDATE";
} | undefined
- One row:
undefinedwhen it conflicts, and the type says so. - An array: the rows inserted only;
[]when every row conflicts.
UpsertAction
{ $action: 'INSERT' | 'UPDATE' }, added to each result of upsert(): 'INSERT' for a new row, 'UPDATE' for an updated one, read from the row's xmax. reportAction: 'suppress' leaves it out of the SQL and of the type.
UpsertReturnableForTable
UpsertReturnableForTable<T, C, E, RA>: one row of what upsert() returns, the ReturningTypeForTable with UpsertAction, unless RA, the reportAction, is 'suppress': db.UpsertReturnableForTable<'ratings', undefined, undefined, undefined> is ratings.JSONSelectable & UpsertAction. It does not add the undefined of updateColumns: [] (doNothing). It needs strictNullChecks: without it, it is the whole JSONSelectable with UpsertAction, whatever C, E and RA.
update()
ts
db.update(table, values, where, options?)Update the rows that match, and return them. The arguments come in the order of the SQL: SET, then WHERE.
table: a table of the schema.values: anUpdatable, with at least one key. Asqlfragment is written as SQL, wheredb.selfis the column, and so is a condition such asconditions.add().raw(),cols(),vals()andparent()throw, as forinsert(). Anundefinedvalue isNULL(Anundefinedvalue).where: aWhereable, or asqlfragment (Conditions).options:returningandextras, as forinsert().
TSYou write Open in playground
ts
const screenings = await db.update('screenings', {
starts_at: db.sql`${db.self} + interval '15 minutes'`,
subtitled: true,
}, { id: 1002 }).run(pool);SQLPostgres runs
sql
UPDATE "screenings" SET ("starts_at", "subtitled") = ROW("starts_at" + interval '15 minutes', $1)
WHERE ("id" = $2)
RETURNING to_jsonb("screenings".*) AS result
values [true, 1002]
::TypeScript knows
ts
const screenings: screenings.JSONSelectable[]
// that is
const screenings: {
cinema_id: number;
film_id: number;
id: number;
starts_at: db.TimestampTzString;
subtitled: boolean | null;
}[]
- Always an array: every row updated,
[]when none matches. SET (…) = ROW(…):ROW()keeps a single column valid since Postgres 10.valueswith no key throws: "nothing to update: an Updatable needs at least one column". Zapatos 6.6.1 sentSET () = ROW(), which Postgres refused (42601).
Every row
An empty Whereable, {}, compiles to WHERE TRUE: update() updates every row of the table, and deletes() deletes them all. An empty list in and(...filters) or not(or(...ids)) reaches every row too.
deletes()
ts
db.deletes(table, where, options?)Delete the rows that match, and return them. delete is a reserved word in JavaScript, hence the name.
table: a table of the schema.where: aWhereable, or asqlfragment (Conditions);{}is every row, as forupdate().options:returningandextras, as forinsert().
TSYou write Open in playground
ts
const removed = await db.deletes('tags', {
film_id: 1003,
}, { returning: ['tag'] }).run(pool);SQLPostgres runs
sql
DELETE
FROM "tags"
WHERE ("film_id" = $1)
RETURNING jsonb_build_object($2::text, "tag") AS result
values [1003, "tag"]
::TypeScript knows
ts
const removed: db.JSONOnlyColsForTable<"tags", "tag"[]>[]
// that is
const removed: {
tag: string;
}[]
- Always an array: every row deleted,
[]when none matches.
DeleteSignatures
The interface of deletes()'s call signature, (table, where, options?) => SQLFragment<…[]>, for a function that wraps it and keeps its types. A wrapper typed with it gets the types of its parameters from it, with no cast, and its calls are checked and typed as those of deletes():
ts
const loggedDeletes: db.DeleteSignatures = (table, where, options) => {
const query = db.deletes(table, where, options);
console.log(query.compile().text);
return query;
};
const removed = loggedDeletes('tags', { film_id: 1003 }, { returning: ['tag'] });truncate()
ts
db.truncate(table, ...options)Empty one table, or several in one statement.
table: a table of the schema, or an array of them.options:'CONTINUE IDENTITY'or'RESTART IDENTITY', then'RESTRICT'or'CASCADE', each optional.
TSYou write Open in playground
ts
const emptied = await db.truncate(['seat_bookings', 'ratings', 'tags'], 'RESTART IDENTITY').run(pool);SQLPostgres runs
sql
TRUNCATE "seat_bookings", "ratings", "tags" RESTART IDENTITY
values []
::TypeScript knows
ts
const emptied: undefined
| Option | SQL it adds | Effect on the type |
|---|---|---|
| none | TRUNCATE "tags" | undefined |
'CONTINUE IDENTITY' | CONTINUE IDENTITY: sequences keep their values, as by default | none |
'RESTART IDENTITY' | RESTART IDENTITY: the tables' sequences restart | none |
'RESTRICT' | RESTRICT: Postgres refuses if a table not in the list references one of them, as by default | none |
'CASCADE' | CASCADE: also empties the tables that reference them | none |
With 'CASCADE', Postgres empties every table that references one of them, at any depth: films takes roles, screenings, ratings and tags with it, and seat_bookings through screenings, each named in a notice, truncate cascades to table "roles":
TSYou write Open in playground
ts
const emptied = await db.truncate('films', 'CASCADE').run(pool);SQLPostgres runs
sql
TRUNCATE "films" CASCADE
values []
::TypeScript knows
ts
const emptied: undefined
.run()resolves with[], where the type saysundefined.- The type takes the options in that order, one of each kind. pgcast writes them as given: in JavaScript, or past the type with
as any,truncate('tags', 'CASCADE', 'RESTART IDENTITY')sendsTRUNCATE "tags" CASCADE RESTART IDENTITY, and Postgres refuses it (42601). - Any other option throws: "Truncate option must be CONTINUE IDENTITY/RESTART IDENTITY/RESTRICT/CASCADE, not '…'". Zapatos 6.6.1 wrote it into the SQL as it was.
An undefined value
In the values of insert(), update() and upsert(), Zapatos 6.6.1 sends undefined as NULL: a NOT NULL DEFAULT column fails, and update() erases the column instead of leaving it.
| In | By default, as in 6.6.1 | With undefinedMeansMissing: true |
|---|---|---|
insert() | NULL | DEFAULT: VALUES ($1, $2, $3, DEFAULT, $4) |
update() | sets the column to NULL, and counts as a key | left out: SET ("title") = ROW($1); with no key left, it throws "nothing to update…" |
upsert() | inserts NULL, then sets it from EXCLUDED | left out: not inserted, nor updated unless updateColumns names it (then from EXCLUDED, so an existing row takes the column's DEFAULT); in several rows, its cell is DEFAULT and counts as missing (Rows with different keys) |
- By default, each sends the SQL of 6.6.1 and warns once per process:
PGCAST_DEP002. - An
undefinedin awhereis unchanged. - An
undefinedinupdateValuesis unchanged too, in both modes: its key is still updated, fromEXCLUDED. So an existing row takes the value inserted, or the column'sDEFAULTwhen the row doesn't insert it, with no warning:upsert('ratings', { film_id: 1002, stars: 4 }, ['film_id', 'stars'], { updateValues: { votes: undefined } })sendsEXCLUDED."votes", and an existing count goes back to 1.
Opt-in
undefinedMeansMissing: true (Config) reads an undefined value as a missing key, as JSON.stringify() does. Without it, pgcast warns as PGCAST_DEP002; it becomes the default in 7.0 (Versions).
ReturningOptionsForTable
{ returning?, extras? }: the options of insert(), upsert(), update() and deletes() that shape each result (Options).
ReturningTypeForTable
ReturningTypeForTable<T, C, E>: one row of what insert(), update() and deletes() return, for the table T, the returning columns C and the extras E (undefined for an option not given): the JSONSelectable, narrowed to C, with the keys of E.
insert() of one row returns it; insert() of an array, update() and deletes() return an array of it. It types a fake result, in a test, from the schema:
ts
type NewFilm = db.ReturningTypeForTable<'films', ['id', 'title'], undefined>;
const inserted: NewFilm = { id: 1000, title: 'The Long Take' };- Stable: type tests pin it to what
.run()gives, here fordb.insert('films', film, { returning: ['id', 'title'] }). Cmay be areadonlytuple, asreturningmay (Options).- It needs
strictNullChecks: without it, it is the wholeJSONSelectable, whateverCandE.