Overview#
PostgreSQL is the ORM's most fully-featured
dialect. Unlike the compatibility-layer dialects (TiDB, CockroachDB, etc.), the
PostgresDialect is a first-class, hand-written implementation on top of the
official node-postgres (pg) driver — it does
not extend any other dialect.| Property | Value |
|---|
| Dialect name | postgres |
| Extends | nothing — implements Dialect directly |
| Driver | pg |
| Cloud SDK | none |
| Default port | 5432 |
| Local runtime | prorm db up --dialect postgres (postgres:16.4-alpine); docker-compose.test.yml |
Key facts (from src/dialects/postgres/index.ts):Dialect name is 'postgres'; the driver library is 'pg'.
Connections are pooled (pg.Pool), with a separate dedicated pg.Client
opened lazily only for LISTEN/NOTIFY (notifications are delivered to the
specific backend that issued LISTEN, so a pooled connection can't be used).
Identifiers are double-quoted: escapeId('my_col') → "my_col", and
embedded double-quotes are doubled (" → "").
Parameters use PostgreSQL's native numbered $1, $2, … placeholders, not
?. The build* methods generate these and return a matching values array.
RETURNING is supported on INSERT / UPDATE / DELETE / UPSERT / MERGE — a
genuine PostgreSQL capability, not emulated.
Quick start#
Install#
$ npm install ts-prorm-orm pg
$ pnpm add ts-prorm-orm pg
$ yarn add ts-prorm-orm pg
$ bun add ts-prorm-orm pg
pg-copy-streams is optional: only the features that need it load it.Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'postgres',
host: 'localhost',
database: 'app',
username: 'app',
password: process.env.DB_PASSWORD,
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'postgres',
url: 'postgres://app:secret@localhost:5432/app',
});
A URL is folded into host, port, database, username and password before the dialect is built; any of those passed alongside it wins.Define a model#
import { DataTypes } from 'ts-prorm-orm';
const User = db.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
name: { type: DataTypes.STRING(100) },
meta: { type: DataTypes.JSONB() },
});
await db.sync(); // CREATE TABLE — see Generated SQL below
meta is JSONB, which the containment query below needs.Query#
import { Op } from 'ts-prorm-orm';
await User.create({ email: 'ada@example.com', name: 'Ada' });
const page = await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Generated SQL#
Each block is the statement the model call above it hands the dialect's query(), captured with the driver stubbed out. The model layer inlines the values.Create table#
db.sync() for the model in the Quick start:CREATE TABLE
Insert, update and delete#
await User.create({ email: 'ada@example.com', name: 'Ada' });
await User.update({ name: 'Grace' }, { where: { email: 'ada@example.com' } });
await User.destroy({ where: { id: 1 } });
Upsert#
await User.upsert({ id: 1, email: 'ada@example.com', name: 'Ada Lovelace' });
upsert() also reads the row by primary key before and after the write, to return the instance.await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
JSON#
Containment on the meta column:import { Op } from 'ts-prorm-orm';
await User.findAll({ where: { meta: { [Op.contains]: { plan: 'pro' } } } });
Transaction with a savepoint#
await db.transaction(async (t) => {
await User.create({ email: 'grace@example.com', name: 'Grace' }, { transaction: t });
// A nested transaction is a SAVEPOINT; its failure undoes only its own work.
await db
.transaction(async (sp) => {
await User.destroy({ where: { id: 1 }, transaction: sp });
throw new Error('keep the row');
}, { transaction: t })
.catch(() => {});
});
Skip locked rows (SKIP LOCKED)#
A worker that claims rows from a queue table should pass over rows another transaction holds rather than wait for them. lock with skipLocked inside a transaction:await db.transaction(async (t) => {
await User.findAll({ where: { name: 'Ada' }, lock: { level: 'UPDATE', skipLocked: true }, transaction: t });
});
Type mapping for the portable descriptors: STRING → VARCHAR(255), auto-increment INTEGER → SERIAL, BOOLEAN → BOOLEAN, DATE → TIMESTAMP, UUID → UUID, JSON → JSON. The type comparison lines the major engines up side by side.Connection#
import { Prorm } from 'ts-prorm-orm';
const db = new Prorm({
dialect: 'postgres',
host: '127.0.0.1',
port: 5432, // default 5432 when omitted
username: 'postgres',
password: 'secret',
database: 'app',
});
The top-level Prorm option is username; internally it is mapped to the pg
driver's user field.
Constructor defaults are applied when omitted: host: 'localhost',
port: 5432, max: 20 (pool size), idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000.
Postgres-specific fields that aren't part of the base connection shape are
passed through the top-level dialectOptions object, which Prorm spreads onto
the dialect config (src/prorm.ts). These are forwarded to pg.Pool:const db = new Prorm({
dialect: 'postgres',
host: 'db.example.com',
username: 'app',
password: 'secret',
database: 'app',
dialectOptions: {
ssl: { rejectUnauthorized: false }, // pg `ssl` — object or boolean
max: 40, // pool size
idleTimeoutMillis: 10000,
connectionTimeoutMillis: 5000,
statementTimeout: 30000, // pg `statement_timeout`
queryTimeout: 30000, // pg `query_timeout`
},
});
The ssl value is passed verbatim to pg (ssl: this.config.ssl), so it
accepts anything node-postgres accepts — true, false, or a TLS options
object with ca/cert/key/rejectUnauthorized.Schemas / search_path#
There is no schema or searchPath connection option on the Postgres
dialect — the config interface (PostgresDialectOptions) does not declare one,
so the connection uses the server's default search_path (normally public).
Schema qualification is done per operation instead:quoteTable(table, schema) prefixes a non-public schema:
quoteTable('users', 'billing') → "billing"."users". A schema of
'public' (or omitted) is left unqualified.
View, materialized-view, trigger, and stored-procedure builders accept an
options.schema and qualify the object name the same way.
createSchema(name) / dropSchema(name, { ifExists, cascade }) and
listSchemas() manage schemas directly.
To pin a search_path for a session, run it as a raw statement:await db.dialect.query('SET search_path TO billing, public');
Data types#
getDataTypeSql maps the ORM's cross-dialect type descriptors to PostgreSQL
types. A raw string is passed through unchanged (with one normalization: the
cross-dialect pseudo-type DATETIME, emitted by the model-sync layer for
createdAt/updatedAt/deletedAt, is rewritten to TIMESTAMP, preserving any
precision — DATETIME(6) → TIMESTAMP(6)).| ORM type descriptor | Generated SQL |
|---|
{ key: 'STRING' } | VARCHAR(255) |
{ key: 'STRING', length: 100 } | VARCHAR(100) |
{ key: 'CHAR' } | CHAR(1) |
{ key: 'TEXT' } | TEXT |
{ key: 'INTEGER' } | INTEGER |
{ key: 'INTEGER', autoIncrement } | SERIAL |
{ key: 'BIGINT' } | BIGINT |
{ key: 'BIGINT', autoIncrement } | BIGSERIAL |
{ key: 'FLOAT' } | REAL |
{ key: 'DOUBLE' } | DOUBLE PRECISION |
{ key: 'DECIMAL', precision, scale } | DECIMAL(precision,scale) |
{ key: 'BOOLEAN' } | BOOLEAN |
{ key: 'DATE' } | TIMESTAMP |
{ key: 'DATEONLY' } | DATE |
{ key: 'TIME' } | TIME |
{ key: 'BLOB' } | BYTEA |
{ key: 'JSON' } | JSON |
{ key: 'JSONB' } | JSONB |
{ key: 'UUID' } | UUID |
{ key: 'ENUM', values: ['a','b'] } | ENUM('a','b') |
{ key: 'ARRAY', type: INTEGER } | INTEGER[] |
{ key: 'INET' } / CIDR / MACADDR | INET / CIDR / MACADDR |
{ key: 'HSTORE' } | HSTORE |
{ key: 'VECTOR', dimensions: 3 } | VECTOR(3) |
Notes on honest limitations:SERIAL/BIGSERIAL are emitted only for auto-increment INTEGER/BIGINT
columns; the identity-column form (GENERATED … AS IDENTITY) is not generated
by the type mapper.
ENUM is emitted inline as ENUM('a','b'), which is not native
PostgreSQL syntax — PostgreSQL enums are named types created with
CREATE TYPE. Use the dialect's createEnumType() helper and reference the
type by name for real enum columns.
VECTOR requires the pgvector extension
(CREATE EXTENSION IF NOT EXISTS vector — see createExtension()).
GEOMETRY/GEOGRAPHY require PostGIS.
An unknown descriptor falls back to VARCHAR(255).
Placeholders: $n numbering#
Every DML builder walks the input left-to-right and emits $1, $2, … in order,
pushing the corresponding JS value onto a values array that lines up with the
placeholders. A Literal (raw SQL) value is inlined verbatim and does not
consume a placeholder number — so numbering stays contiguous across a mix of
bound values and literals.const { sql, values } = db.dialect.buildInsertQuery('users', {
name: 'Ada',
age: 36,
});
// sql: INSERT INTO "users" ("name", "age") VALUES ($1, $2)
// values: ['Ada', 36]
For UPDATE, the SET placeholders are numbered first, then the WHERE values
are appended to the same values array (in order) so the caller passes one flat
array to the driver.RETURNING#
INSERT, UPDATE, DELETE, UPSERT, and MERGE all accept a returning option:returning: true → appends RETURNING *.
returning: ['id', 'created_at'] → appends
RETURNING "id", "created_at" (each column identifier-quoted).
const { sql, values } = db.dialect.buildInsertQuery(
'users',
{ name: 'Grace', email: 'grace@example.com' },
{ returning: ['id', 'created_at'] },
);
// INSERT INTO "users" ("name", "email") VALUES ($1, $2) RETURNING "id", "created_at"
db.dialect.buildDeleteQuery('sessions', { userId: 7 }, { returning: true });
// DELETE FROM "sessions" WHERE "userId" = $1 RETURNING *
The query() method surfaces RETURNING rows: for a non-SELECT statement that
returns rows, the result's rows array is populated and rowCount is set to
the number of returned rows.ON CONFLICT upsert#
PostgreSQL upserts use INSERT … ON CONFLICT (cols) DO UPDATE SET … with the
special excluded pseudo-row referencing the values that would have been
inserted. Two builders produce this:buildInsertQuery(table, values, { upsert: true, conflictFields, … })
buildUpsertQuery(table, values, { conflictFields, updateOnDuplicate?, … })
conflictFields is required. PostgreSQL's ON CONFLICT must name the
columns of an existing unique or exclusion constraint — it cannot be inferred
from the inserted columns — so both builders throw if conflictFields is
missing or empty.const { sql, values } = db.dialect.buildUpsertQuery(
'kv',
{ k: 'theme', v: 'dark' },
{ conflictFields: ['k'] },
);
// INSERT INTO "kv" ("k", "v") VALUES ($1, $2)
// ON CONFLICT ("k") DO UPDATE SET "k" = excluded."k", "v" = excluded."v"
updateOnDuplicate (on buildUpsertQuery) restricts which columns the
DO UPDATE SET touches; otherwise every inserted column is updated:db.dialect.buildUpsertQuery(
'users',
{ id: 1, name: 'Ada', seen_at: new Date() },
{ conflictFields: ['id'], updateOnDuplicate: ['seen_at'], returning: ['id'] },
);
// INSERT INTO "users" ("id", "name", "seen_at") VALUES ($1, $2, $3)
// ON CONFLICT ("id") DO UPDATE SET "seen_at" = excluded."seen_at" RETURNING "id"
For multi-branch merge logic (update on one condition, delete on another, insert
otherwise), buildMergeQuery emits a PostgreSQL 15+ MERGE … WHEN [NOT]
MATCHED statement instead. Its RETURNING support requires PostgreSQL 17+.Arrays#
escapeArray produces a PostgreSQL ARRAY[...] literal (strings are
double-quoted, non-scalar elements are JSON-encoded and quoted). PostgreSQL
infers the element type from the target column, so untyped literals work for
both text[] and numeric arrays:db.dialect.escape(['red', 'green', 'blue']);
// ARRAY["red", "green", "blue"]
Array operator helpers build predicate fragments:| Helper | Emitted SQL |
|---|
buildArrayContains('tags', ['a','b']) | "tags" @> ARRAY["a", "b"] |
buildArrayContainedBy('tags', ['a']) | "tags" <@ ARRAY["a"] |
buildArrayOverlaps('tags', ['a','b']) | "tags" && ARRAY["a", "b"] |
buildArrayAny('tags', 'a') | 'a' = ANY("tags") |
buildArrayAll('scores', 10, '>') | 10 > ALL("scores") |
// rows whose "tags" array contains both 'sql' and 'db'
const frag = db.dialect.buildArrayContains('tags', ['sql', 'db']);
const rows = await db.dialect.query(`SELECT * FROM "posts" WHERE ${frag}`);
JSON / JSONB#
Both JSON and JSONB column types are mapped directly. buildJsonPathQuery
constructs path predicates and containment/existence checks:// data #> '{profile,age}' > $2
db.dialect.buildJsonPathQuery('data', '{profile,age}', 30, '>');
// { sql: '"data" #> $1 > $2', values: ['{profile,age}', 30] }
Operator handling (real PostgreSQL semantics, verified in the dialect source):Containment @> / <@ — emitted as raw operators with a ::jsonb cast on
the argument: "data" @> $1::jsonb. (PostgreSQL has no function form for
containment, only the operators.)
Key existence ? — emitted via the jsonb_exists(col, $1::text)
function.
Any-key existence ?| — emitted via jsonb_exists_any(col, $1::text[]),
which correctly takes a text[] of candidate keys (distinct from the
single-key ?).
Comparison operators (=, !=, >, <, >=, <=, ~, ~*) with a path
use the #> path-extraction operator; without a path they compare the column
directly.
// documents whose JSONB "meta" contains {"active": true}
const q = db.dialect.buildJsonPathQuery('meta', '', { active: true }, '@>');
// q.sql: "meta" @> $1::jsonb
// q.values: [{ active: true }]
const rows = await db.dialect.query(
`SELECT * FROM "docs" WHERE ${q.sql}`,
{ replacements: q.values },
);
Beyond CRUD#
The dialect also implements genuine PostgreSQL features backing higher ORM
layers, each grounded in the driver: full-text search
(buildFullTextSearchQuery, wrapping to_tsvector/plainto_tsquery with an
optional ts_rank/ts_rank_cd ranking), LISTEN/NOTIFY over a dedicated
non-pooled client (listen/notify/unlisten, with notify using
SELECT pg_notify($1, $2)), session-level advisory locks
(pgAdvisoryLock/pgTryAdvisoryLock, each holding a dedicated pooled
connection until release()), server-side cursor streaming (queryStream),
and range/list/hash partitioning helpers.Caveats / honest limitations#
build* methods generate SQL; they do not execute. They return
{ sql, values }; you (or the ORM's higher layers) pass them to query().
conflictFields is mandatory for upserts — both upsert builders throw
without it (see the ON CONFLICT section).
Inline ENUM('a','b') is not native PostgreSQL syntax. Prefer named enum
types via createEnumType().
SET LIMIT on UPDATE/DELETE is emitted when options.limit is given, but
standard PostgreSQL does not support LIMIT on UPDATE/DELETE; it will
error on a real server unless an extension permits it. Use a subquery on the
primary key instead.
No global schema/searchPath config. Qualify per operation, or SET
search_path via a raw statement.
escape() inlines values; the parameterized build* path ($n +
values) is preferred for anything derived from user input.
Capabilities#
Computed from the code — capabilitiesOf() (src/transaction-support.ts),
whereSupportOf() (src/query-builders/expression-render.ts),
sqlObjectCapabilitiesOf() (src/sql-objects/capabilities.ts) and the
builders — by the scratch test described in the
capability matrix.| Capability | postgres |
|---|
| Transactions atomic | yes |
| Savepoints | yes |
| Isolation | up to SERIALIZABLE |
| Transaction modes | read-only yes; deferrable yes; constraint deferral yes |
| RETURNING | RETURNING on INSERT |
| Upsert | INSERT … ON CONFLICT (…) DO UPDATE |
| JSON column | JSON (JSONB → JSONB) |
| Vector column | VECTOR(3) |
| Array column | INTEGER[] |
| WHERE builder | 62 operator keys; $and/$or/$not; $exists: yes; $in subquery: yes; expression operands: yes |
| SQL objects | family postgres: triggers yes; procedures yes; functions multiStatement; materialized views native; sequences yes; enums named; RLS policy |
| Streaming | queryStream() present; LISTEN present; createExtension() present |
Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16