Oracle Database is an enterprise-grade relational database. prorm talks to it through
the oracledb driver. This guide covers how
to connect, the data-type mapping, sequences, Oracle's positional bind style, and the
Oracle-specific quirks you need to know about.The implementation lives in oracle.
It targets Oracle 19c / 21c primarily (several 23c-only conveniences are emulated — see
Quirks). This dialect is functional but has gaps; the honest state of each
feature is called out below.Overview#
Driver: oracledb (OracleDialect.library === 'oracledb')
Identifier quoting: double quotes ("MyTable"). Unquoted identifiers fold to
uppercase, so mixed-case names created through the ORM must always be referenced quoted.
Bind style: positional :1, :2, … (Oracle has no ? placeholder support).
Booleans: no native boolean — mapped to NUMBER(1).
Auto-increment: no AUTO_INCREMENT/SERIAL — use sequences. Pooling: always on. connect() builds an oracledb connection pool.
| Property | Value |
|---|
| Dialect name | oracle |
| Extends | nothing — implements Dialect directly |
| Driver | oracledb |
| Cloud SDK | none |
| Default port | 1521 |
| Local runtime | prorm db up --dialect oracle (gvenzl/oracle-free:23.5-slim); docker-compose.test.yml |
Quick start#
Install#
$ npm install ts-prorm-orm oracledb
$ pnpm add ts-prorm-orm oracledb
$ yarn add ts-prorm-orm oracledb
$ bun add ts-prorm-orm oracledb
Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'oracle',
host: 'localhost',
database: 'FREEPDB1', // the service name
username: 'app',
password: process.env.DB_PASSWORD,
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'oracle',
url: 'oracle://app:secret@localhost:1521/FREEPDB1',
});
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) },
});
await db.sync(); // CREATE TABLE — see Generated SQL below
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.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' });
MERGE statement
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,
});
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 → VARCHAR2(255), auto-increment INTEGER → NUMBER(10), BOOLEAN → NUMBER(1), DATE → DATE, UUID → RAW(16), JSON → CLOB. The type comparison lines the major engines up side by side.Connection#
import { Prorm } from 'ts-prorm-orm';
const prorm = new Prorm({
dialect: 'oracle',
host: 'localhost',
port: 1521, // default 1521
database: 'ORCL', // the service name
username: 'system',
password: 'password',
});
await prorm.connect();
Under the hood the dialect assembles an Easy Connect string from your options:// From OracleDialect.connect():
connectString: `${host}:${port}/${database}` // e.g. "localhost:1521/ORCL"
Honest note: there is no dedicated connectString or TNS-alias option. The connect
string is always built from host, port, and database. If you need a full descriptor
or a wallet-based connection, those are not currently wired through the dialect options.
Working directly with the dialect#
You can also instantiate the dialect on its own:import { createOracleDialect } from 'ts-prorm-orm';
const oracle = createOracleDialect({
host: 'localhost',
port: 1521,
database: 'ORCL',
username: 'system',
password: 'password',
thinMode: true, // stay in pure-JS thin mode; skip Instant Client init
pool: { max: 10, min: 2, increment: 2, timeout: 30 },
schema: 'APP', // default schema for sequences/procedures/triggers
});
await oracle.connect();
const { rows } = await oracle.query('SELECT * FROM DUAL');
await oracle.disconnect();
Thin vs thick mode#
// thinMode: true -> pure-JS driver, no Oracle Instant Client required.
// thinMode: false -> calls oracledb.initOracleClient() (thick mode, needs Instant Client).
By default (thinMode unset/false) the dialect calls oracledb.initOracleClient(), which
requires an Instant Client installation. Set thinMode: true to stay in the driver's
pure-JS mode.Connection retry#
Connection-level errors can be retried automatically:const oracle = createOracleDialect({
/* ... */
retry: {
max: 3,
timeout: 5000,
backoff: true,
backoffMultiplier: 2,
backoffMax: 10000,
match: ['ORA-12514', 'ORA-12541', 'ORA-12154', 'ECONNREFUSED'],
},
});
Data types#
The ORM's abstract types map to Oracle as follows (see mapDataType()):| ORM type | Oracle type | Notes |
|---|
STRING(n) | VARCHAR2(n) | default n = 255 |
CHAR(n) | CHAR(n) | default n = 1 |
TEXT / MEDIUMTEXT / LONGTEXT | CLOB | |
INTEGER | NUMBER(10) | length overridable |
BIGINT | NUMBER(19) | |
DECIMAL(p,s) / NUMERIC / NUMBER | NUMBER(p,s) | precision/scale honored |
FLOAT / DOUBLE | FLOAT | |
BOOLEAN | NUMBER(1) | no native boolean — true/false escape to 1/0 |
DATE | DATE | |
DATETIME / TIMESTAMP | TIMESTAMP | |
DATEONLY | DATE | |
TIME | VARCHAR2(8) | stored as a string |
BLOB | BLOB | |
BINARY(n) / VARBINARY / RAW | RAW(n) | |
JSON | CLOB | stored as text |
JSONB | BLOB | |
UUID | RAW(16) | |
GEOMETRY | SDO_GEOMETRY | |
CLOB / NCLOB / BFILE / XMLTYPE / LONG_RAW | native equivalents | Oracle-specific |
Anything unrecognized falls back to VARCHAR2(255).await oracle.createTable('EMPLOYEES', {
ID: { type: 'INTEGER', primaryKey: true },
NAME: { type: { key: 'STRING', length: 100 }, allowNull: false },
SALARY: { type: { key: 'DECIMAL', precision: 12, scale: 2 } },
IS_ACTIVE: { type: 'BOOLEAN', defaultValue: true }, // -> NUMBER(1) DEFAULT 1
RESUME: { type: 'BLOB' },
BIO: { type: 'TEXT' }, // -> CLOB
CREATED_AT: { type: 'TIMESTAMP' },
});
Value escaping quirks#
Date values escape to TO_DATE('YYYY-MM-DD HH24:MI:SS', ...).
Buffer values escape via HEXTORAW('deadbeef') so raw bytes land in RAW/BLOB.
Booleans escape to '1' / '0' (consistent with the NUMBER(1) mapping).
Sequences#
Oracle has no auto-increment column; you generate surrogate keys with sequences.await oracle.createSequence({
name: 'EMPLOYEE_SEQ',
startWith: 1000,
incrementBy: 1,
cache: 20,
schema: 'APP',
});
// Fetch the next value (SELECT ... .NEXTVAL FROM DUAL)
const nextId = await oracle.nextSequenceValue('EMPLOYEE_SEQ');
// Existence check / listing
await oracle.hasSequence('EMPLOYEE_SEQ', 'APP');
const all = await oracle.listSequences(); // ["APP.EMPLOYEE_SEQ", ...]
// Drop (ifExists is emulated for pre-23c — see Quirks)
await oracle.dropSequence('EMPLOYEE_SEQ', { ifExists: true, schema: 'APP' });
Supported createSequence options: startWith, incrementBy, minvalue, maxvalue,
cycle, cache, nocache, order, replace, ifNotExists, schema.Gap — no CURRVAL helper. Only nextSequenceValue() (NEXTVAL) is implemented.
There is no currSequenceValue() / CURRVAL method on the dialect. If you need the
current value, issue it yourself via query():const { rows } = await oracle.query(
'SELECT "EMPLOYEE_SEQ".CURRVAL AS "value" FROM DUAL',
);
(Remember CURRVAL is only valid in a session that has already called NEXTVAL.)
Note — default schema is SYSTEM. Sequence names are qualified with
options.schema → config.schema → 'SYSTEM'. Set a schema in the dialect options
or per call to avoid accidentally targeting SYSTEM.
Bind parameters (:n style)#
Oracle uses positional binds :1, :2, … — there is no ? placeholder. The query
builders emit this style, and query() accepts a positional array (or a named object for
:name binds):// Named binds
await oracle.query(
'SELECT * FROM "EMPLOYEES" WHERE "DEPT_ID" = :dept',
{ replacements: { dept: 10 } },
);
// Positional binds
await oracle.query(
'SELECT * FROM "EMPLOYEES" WHERE "SALARY" > :1 AND "DEPT_ID" = :2',
{ replacements: [50000, 10] },
);
buildInsertQuery / buildUpdateQuery generate :n placeholders:const { sql, values } = oracle.buildInsertQuery('EMPLOYEES', {
ID: 1001, NAME: 'Ada', SALARY: 90000,
});
// INSERT INTO "EMPLOYEES" ("ID", "NAME", "SALARY") VALUES (:1, :2, :3)
RETURNING ... INTO#
Because Oracle has no ? binds, RETURNING uses OUT binds that continue the :n
numbering after the insert's own binds:const { sql } = oracle.buildInsertQuery(
'EMPLOYEES',
{ NAME: 'Ada', SALARY: 90000 },
{ returning: ['ID'] },
);
// INSERT INTO "EMPLOYEES" ("NAME", "SALARY") VALUES (:1, :2) RETURNING "ID" INTO :3
Passing returning: true returns the ROWID instead.Caveat — mixed placeholder styles. The WHERE-clause builder emits ? markers while
INSERT/UPDATE SET clauses emit :n. For raw statements prefer building your own SQL
with explicit :n binds (or interpolate through replaceReplacements()), rather than
relying on ? reaching the driver.
buildSelectQuery paginates by wrapping the query and filtering on ROWNUM:const { sql } = oracle.buildSelectQuery({
tableName: 'EMPLOYEES',
order: { SALARY: 'DESC' },
limit: 10,
offset: 20,
});
// SELECT * FROM ( ... ) WHERE ROWNUM > 20 AND ROWNUM <= 30
Gap — buildLimitOffset(). The standalone buildLimitOffset() helper emits only a
bare WHERE ROWNUM <= n and does not skip the first offset rows. Prefer
buildSelectQuery (which wraps correctly), or use queryStream() for streaming, which
routes through a correct ROWNUM-windowed helper.
Upsert (MERGE)#
buildUpsertQuery compiles to a MERGE INTO ... USING (SELECT ... FROM DUAL) statement and
supports composite conflict keys:const { sql } = oracle.buildUpsertQuery(
'EMPLOYEES',
{ ID: 1001, NAME: 'Ada', SALARY: 90000 },
{ conflictFields: ['ID'] },
);
// MERGE INTO "EMPLOYEES" USING (SELECT :1 AS "ID", :2 AS "NAME", :3 AS "SALARY" FROM DUAL) "src"
// ON ("EMPLOYEES"."ID" = "src"."ID")
// WHEN MATCHED THEN UPDATE SET ...
// WHEN NOT MATCHED THEN INSERT (...) VALUES (...)
A general-purpose buildMergeQuery() is also available for hand-written merges (composite
on keys, conditional WHEN MATCHED, WHEN MATCHED ... THEN DELETE, etc.).Oracle-specific query builders#
These are implemented and Oracle-idiomatic:buildWindowFunction() — analytic functions, e.g.
ROW_NUMBER() OVER (PARTITION BY "dept" ORDER BY "salary" DESC).
buildCTE() — WITH ... AS (...). Recursive simply by supplying unionQuery
(Oracle has no RECURSIVE keyword).
buildConnectByQuery() — hierarchical START WITH ... CONNECT BY PRIOR queries with
optional NOCYCLE, ORDER SIBLINGS BY, and LEVEL/CONNECT_BY_ISLEAF pseudo-columns.
Transactions#
const tx = await oracle.startTransaction({ isolationLevel: 'SERIALIZABLE' });
// ... do work ...
await oracle.commitTransaction(tx); // commits + closes the tx connection
// or: await oracle.rollbackTransaction(tx);
Only READ COMMITTED and SERIALIZABLE (plus READ ONLY) are accepted; the ANSI
READ UNCOMMITTED / REPEATABLE READ levels throw a DatabaseError (Oracle doesn't have
them). Savepoint SQL helpers (createSavepointSQL, releaseSavepointSQL,
rollbackToSavepointSQL) are provided.Important gap — query() does not join an open transaction. startTransaction()
holds its own pooled connection, but query() acquires a separate connection from the
pool and runs with autoCommit: true. Statements executed through query() are therefore
not part of a transaction started with startTransaction(). To run statements
transactionally, execute them on tx's own connection directly.
Quirks#
IF [NOT] EXISTS is emulated for 19c/21c#
Native IF [NOT] EXISTS on CREATE/DROP is 23c-only and raises ORA-00922 on older
releases. For tables, schemas (users), and sequences, the dialect instead runs the plain
DDL wrapped in a PL/SQL block that swallows the specific "already exists" / "does not exist"
ORA- code (e.g. ORA-00955, ORA-00942, ORA-01918, ORA-02289). So ifExists /
ifNotExists work portably.A schema is a user#
Oracle has no standalone schema object — a schema is a user. createSchema() therefore
runs CREATE USER with a randomly generated password; if you need a specific password,
create the user through the user-management API instead. dropSchema() runs
DROP USER ... CASCADE.queryStream()#
Streaming uses a dedicated ROWNUM-windowed helper (createOracleQueryStream) rather than
the buggy buildLimitOffset(), so paginated streams skip offsets correctly.Partitioning#
createPartitionedTable() supports RANGE, LIST, HASH, INTERVAL, and REFERENCE
partitioning, plus subpartitions. Since Oracle has no PostgreSQL-style ATTACH/DETACH
PARTITION, attachPartition() / detachPartition() are implemented via
ALTER TABLE ... EXCHANGE PARTITION ... WITH TABLE .... listPartitions(),
modifyPartition(), and addPartition() are available.Materialized views#
createMaterializedView() supports BUILD IMMEDIATE/DEFERRED, REFRESH FAST/COMPLETE/FORCE,
and ON COMMIT/DEMAND. Oracle has no OR REPLACE/IF NOT EXISTS for MVs, so replace
drops-then-recreates. refreshMaterializedView() calls DBMS_MVIEW.REFRESH (there is no
REFRESH MATERIALIZED VIEW statement in Oracle); PostgreSQL's CONCURRENTLY is ignored and
treated as FORCE.Unsupported features (these throw)#
The following are intentionally not implemented and throw an explanatory error:Foreign Data Wrappers (createForeignDataWrapper, createForeignServer,
createForeignTable, user mappings, …) — Postgres-specific; use CREATE DATABASE LINK.
Extensions (createExtension, dropExtension, …) — Postgres-specific.
changeOwner — not supported in Oracle.
addConstraint / removeConstraint — throw. Use createConstraint() /
dropConstraint() (which are implemented) instead.
createSecurityPolicy / dropSecurityPolicy — throw ("Security policies are not
supported"). (Oracle VPD is not wired through these methods.)
See also#
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 | oracle |
|---|
| Transactions atomic | yes |
| Savepoints | yes (no explicit RELEASE) |
| Isolation | up to SERIALIZABLE (accepted: READ COMMITTED, SERIALIZABLE); native levels READ ONLY |
| Transaction modes | read-only no; deferrable no; constraint deferral no |
| RETURNING | RETURNING on INSERT |
| Upsert | MERGE INTO … WHEN MATCHED / WHEN NOT MATCHED |
| JSON column | CLOB (JSONB → BLOB) |
| Vector column | VECTOR(3, FLOAT32) |
| Array column | VARCHAR2(255) |
| WHERE builder | 34 operator keys; $and/$or/$not; $exists: no; $in subquery: no; expression operands: no |
| SQL objects | family oracle: triggers yes; procedures yes; functions multiStatement; materialized views native; sequences yes; enums check; RLS no |
| Streaming | queryStream() present; LISTEN absent; createExtension() present |
Testing#
tests/dialects/oracle.test.ts exercises the dialect with a mocked driver, so nothing touches a server. A live instance: prorm db up --dialect oracle (see Local databases).Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16