Overview#
Exasol is an in-memory, column-oriented, massively-parallel
(MPP) analytic database. Its SQL grammar and wire behavior are close enough to
PostgreSQL that this dialect extends PostgresDialect and reuses the pg-style grammar
(double-quoted identifiers, $n positional placeholders, plain SELECT/WHERE/ORDER
BY/GROUP BY/JOIN, LIMIT n OFFSET m) as its base, overriding only the places where
Exasol genuinely differs.| Property | Value |
|---|
| Dialect name | exasol |
| Extends | PostgresDialect (postgres) |
| Driver | pg |
| Cloud SDK | none |
| Default port | 8563 |
| Local runtime | self-hosted; no image in this repo |
Source: src/dialects/exasol/index.ts (extends src/dialects/postgres/index.ts).Key differences from PostgreSQL captured by this dialect:Default SQL port is 8563 (not 5432).
CREATE TABLE supports the MPP DISTRIBUTE BY and PARTITION BY clauses.
No SERIAL / BIGSERIAL — auto-increment columns use an IDENTITY constraint on a
numeric (DECIMAL) column.
Type mapping differs: TEXT becomes VARCHAR(2000000), integers become DECIMAL,
etc. (see Type mapping). Bulk load/unload uses IMPORT / EXPORT rather than PostgreSQL's COPY.
No RETURNING clause and no ON CONFLICT — the inherited write builders are
overridden to strip RETURNING and to express upserts as a MERGE.
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 is the same driver the postgres dialect uses. pg-copy-streams is optional: only the features that need it load it.[!CAUTION] A SQL-generation layer
The driver the catalog lists, pg, is the one the parent dialect loads; it does not speak Exasol's own wire protocol, so connecting to a live server is unverified. The builders — everything under Generated SQL — are what this dialect is for. See Caveats.
Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'exasol',
host: 'exasol.internal',
database: 'sys',
username: 'sys',
password: process.env.DB_PASSWORD,
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'exasol',
url: 'exasol://sys:secret@exasol.internal:8563/sys',
});
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.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' });
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#
await db.transaction(async (t) => {
await User.create({ email: 'grace@example.com', name: 'Grace' }, { transaction: t });
await User.update({ name: 'Grace Hopper' }, { where: { email: 'grace@example.com' }, transaction: t });
});
[!WARNING] No savepoints
Nested transactions need savepoints, and exasol has none. Run the work in a single transaction, or restructure it so nothing nests.
Type mapping for the portable descriptors: STRING → VARCHAR(255), auto-increment INTEGER → DECIMAL(18,0), BOOLEAN → BOOLEAN, DATE → TIMESTAMP, UUID → HASHTYPE(16 BYTE), JSON → VARCHAR(2000000). The type comparison lines the major engines up side by side.Connection#
import { Prorm } from 'ts-prorm-orm';
const orm = new Prorm({
dialect: 'exasol',
host: 'exasol.internal',
port: 8563, // default if omitted; the dialect sets 8563 when no port is given
username: 'sys',
password: 'exasol',
database: 'sys',
});
You can also construct the dialect directly (this is how the connection-free test
suite exercises it):import { ExasolDialect, createExasolDialect } from 'prorm/dialects/exasol';
const dialect = new ExasolDialect({});
// or:
const dialect2 = createExasolDialect();
The constructor applies port: 8563 as a default but any caller-supplied port still
wins:// From ExasolDialect constructor:
super({ port: 8563, ...config });
Row locks and PostgreSQL-only operators#
The dialect extends PostgresDialect, but the PostgreSQL-only parts of its
SELECT are not Exasol SQL, so they are rendered natively or refused.Exasol has no row-level locking, so any lock option is refused:Exasol has no row-level locking, so `lock` cannot be honoured. Remove the `lock` option; a lock the caller believes it holds but does not is worse than an error.
The PostgreSQL JSON, array, range and full-text operators (Op.contains as
@>, Op.overlap as &&, ?, range operators, @@) are refused with an
UnsupportedWhereError, and the statement is not sent:Cannot render WHERE condition at tags.$contains for exasol: the operator '$contains' is not implemented by its WHERE builder. The statement was not sent, because running it without the condition would match rows it should not.
[!NOTE]
Before 4.9.2 this dialect sent PostgreSQL's FOR SHARE / SKIP LOCKED
clauses and the @> / && operators unchanged, and the server rejected
them.
What differs from Postgres#
ExasolDialect extends PostgresDialect (postgres) and
overrides 24 of its methods (Object.getOwnPropertyNames(ExasolDialect.prototype),
computed by the scratch test); everything else is inherited unchanged.| Area | Overridden |
|---|
| DML builders | buildDeleteQuery, buildInsertQuery, buildMergeSQL, buildUpdateQuery, buildUpsertQuery |
| Types | buildColumnSql, canonicalTypeSql, ensureEnumTypes, getDataTypeSql, legacyExasolTypeSql, mapNativeType, unsupportedType |
| DDL and schema | addColumn, buildCreateTableSQL, buildImportSQL, changeColumn, createTable |
| Other | buildExportSQL, columnList, exportData, formatDefault, importData, lockClauseSpec, unsupportedWhereOperators |
Exasol-specific features#
All of the SQL-building helpers below are synchronous and can be exercised without a
live connection.CREATE TABLE with DISTRIBUTE BY / PARTITION BY#
buildCreateTableSQL(tableName, columns, options?) emits an ordinary column list, then
appends Exasol's MPP clauses when the options are present. DISTRIBUTE BY (hash
distribution across cluster nodes) is always emitted before PARTITION BY (on-disk
partitioning within a node). Both distributeBy and partitionBy accept a single
column name or an array of column names.const sql = dialect.buildCreateTableSQL(
'sales',
{
id: { type: { key: 'INTEGER' }, primaryKey: true, autoIncrement: true },
region: { type: { key: 'STRING', length: 50 } },
amount: { type: { key: 'DECIMAL', precision: 10, scale: 2 } },
},
{ distributeBy: 'id', partitionBy: 'region' },
);
With a multi-column distribution list (distributeBy: ['region', 'id']):Pass { ifNotExists: true } to emit CREATE TABLE IF NOT EXISTS.IDENTITY auto-increment (not SERIAL)#
Exasol has no SERIAL / BIGSERIAL. Auto-increment is expressed as an IDENTITY
column constraint on a numeric (DECIMAL) column. This applies both in CREATE TABLE
and in addColumn:await dialect.addColumn('sales', 'seq', {
type: { key: 'INTEGER' },
autoIncrement: true,
});
When a column is autoIncrement, any defaultValue is skipped.Column constraints (UNIQUE / REFERENCES)#
Column-level UNIQUE and REFERENCES constraints are supported:const sql = dialect.buildCreateTableSQL('orders', {
id: { type: { key: 'INTEGER' }, primaryKey: true, autoIncrement: true },
email: { type: { key: 'STRING', length: 200 }, unique: true },
customer_id: {
type: { key: 'INTEGER' },
references: { table: 'customers', field: 'id' },
},
});
MODIFY COLUMN#
Exasol uses ALTER TABLE ... MODIFY COLUMN col type rather than PostgreSQL's
ALTER COLUMN col TYPE type:await dialect.changeColumn('sales', 'region', {
type: { key: 'STRING', length: 100 },
allowNull: false,
});
IMPORT / EXPORT (bulk load / unload)#
buildImportSQL and buildExportSQL emit Exasol's file-based bulk load/unload
statements. Options: format ('CSV' | 'FBV', defaults to 'CSV'), at (the
AT '<connection-or-url>' endpoint), and file (the FILE '<path>' to read/write).
Single quotes in at/file are escaped by doubling.dialect.buildImportSQL('sales', {
at: 'http://files.example.com',
file: 'data.csv',
});
dialect.buildExportSQL('sales', {
at: 'http://files.example.com',
file: 'out.csv',
});
The schema option (if any) is not used by these builders — qualify the table name
yourself if you need a schema prefix.
Upsert#
Exasol has no INSERT ... ON CONFLICT, so upserts are expressed as a MERGE statement.
Two entry points build the same MERGE:buildUpsertQuery(tableName, values, options) — direct upsert.
buildInsertQuery(tableName, values, options) with options.upsert set — routes to
the same MERGE builder (buildMergeSQL).
Both require conflictFields — the key column(s) to match existing rows on. Without
them the builder throws (conflictFields is required), because Exasol's MERGE needs an
explicit ON condition that cannot be inferred from the inserted columns.The single row is presented as a one-row derived source via a FROM-less
SELECT $1 AS "col", ... (Exasol permits SELECT without FROM). Rows are matched on
conflictFields; matched rows are updated and unmatched rows are inserted. The conflict
columns are excluded from the UPDATE SET clause (Exasol forbids updating the
columns that appear in the ON condition).const { sql, values } = dialect.buildUpsertQuery(
'sales',
{ id: 1, region: 'EU', amount: 10 },
{ conflictFields: ['id'] },
);
// values -> [1, 'EU', 10]
Routing an INSERT to a MERGE (equivalent output):dialect.buildInsertQuery(
'sales',
{ id: 1, region: 'EU', amount: 10 },
{ upsert: true, conflictFields: ['id'] },
);
// sql contains 'MERGE INTO "sales"' and never 'ON CONFLICT'
Explicit update list. Pass updateOnDuplicate to limit which columns are updated
on match (conflict columns are still excluded):dialect.buildUpsertQuery(
'sales',
{ id: 1, region: 'EU', amount: 10 },
{ conflictFields: ['id'], updateOnDuplicate: ['amount'] },
);
// WHEN MATCHED THEN UPDATE SET dst."amount" = src."amount"
All columns are keys. If every column is a conflict key, no WHEN MATCHED clause
is emitted (there is nothing left to update):dialect.buildUpsertQuery('link', { a: 1, b: 2 }, { conflictFields: ['a', 'b'] });
// no 'WHEN MATCHED'; still 'WHEN NOT MATCHED THEN INSERT'
Composite conflict keys are joined with AND in the ON condition: Type mapping#
getDataTypeSql overrides the PostgreSQL mapping for the types that differ on Exasol.
Anything not listed below delegates to the PostgreSQL base mapping.ORM type (key) | Exasol SQL | Notes |
|---|
TEXT | VARCHAR(2000000) | Exasol has no TEXT; uses its max VARCHAR width |
STRING (length n) | VARCHAR(n) | Defaults to VARCHAR(255) when no length |
CHAR (length n) | CHAR(n) | Defaults to CHAR(1) |
INTEGER | DECIMAL(18,0) | No SERIAL; auto-increment via IDENTITY |
BIGINT | DECIMAL(36,0) | Exasol has no narrow integer types |
DECIMAL (p, s) | DECIMAL(p,s) | Defaults to DECIMAL(18,0) |
BOOLEAN | BOOLEAN | Straight through |
DATE | TIMESTAMP / TIMESTAMP(p) | With precision when provided |
DATEONLY | DATE | |
DATETIME (string form) | TIMESTAMP (and TIMESTAMP(p)) | The DATETIME pseudo-type is normalized to TIMESTAMP |
Examples from the test suite:dialect.getDataTypeSql({ key: 'STRING', length: 100 }); // 'VARCHAR(100)'
dialect.getDataTypeSql({ key: 'STRING' }); // 'VARCHAR(255)'
dialect.getDataTypeSql({ key: 'TEXT' }); // 'VARCHAR(2000000)'
dialect.getDataTypeSql({ key: 'DECIMAL', precision: 12, scale: 4 }); // 'DECIMAL(12,4)'
dialect.getDataTypeSql({ key: 'INTEGER' }); // 'DECIMAL(18,0)'
dialect.getDataTypeSql({ key: 'BOOLEAN' }); // 'BOOLEAN'
dialect.getDataTypeSql({ key: 'DATE' }); // 'TIMESTAMP'
CRUD (inherited from PostgreSQL, with RETURNING/LIMIT stripped)#
INSERT uses $n placeholders and never emits RETURNING (the returning option is
ignored):dialect.buildInsertQuery('sales', { region: 'EU', amount: 10 });
// sql: INSERT INTO "sales" ("region", "amount") VALUES ($1, $2)
// values: ['EU', 10]
Bulk insert (inherited from the PG base) emits one VALUES tuple per row.UPDATE and DELETE inherit the PostgreSQL grammar, but Exasol supports neither
RETURNING nor LIMIT on them, so both options are stripped before delegating to the
base builder:dialect.buildUpdateQuery('sales', { amount: 99 }, { region: 'EU' });
// UPDATE "sales" SET "amount" = $1 WHERE "region" = $2 ; values [99, 'EU']
dialect.buildDeleteQuery('sales', { region: 'EU' });
// DELETE FROM "sales" WHERE "region" = $1 ; values ['EU']
// returning/limit are dropped:
dialect.buildUpdateQuery('sales', { amount: 99 }, { region: 'EU' }, { returning: true, limit: 5 });
// sql contains neither 'RETURNING' nor 'LIMIT'
Plain SELECT (WHERE, ORDER BY, GROUP BY / HAVING, JOIN) and buildLimitOffset are
inherited unchanged — Exasol's SELECT grammar matches the PostgreSQL forms the base
emits, including LIMIT n OFFSET m:dialect.buildLimitOffset(5, 15); // ' LIMIT 5 OFFSET 15'
Caveats#
Identifier case folding. Exasol upper-cases unquoted identifiers (like Oracle).
This dialect quotes identifiers with double quotes (inherited from PostgresDialect),
so the ORM's mixed-case names are preserved exactly rather than folded to upper case.
No RETURNING. Exasol has no RETURNING on INSERT/UPDATE/DELETE. The write
builders deliberately omit it even when options.returning is requested — you will not
get generated keys or affected rows back inline.
No ON CONFLICT. Upserts are always emitted as MERGE and require conflictFields
(the builder throws otherwise).
No LIMIT on UPDATE/DELETE. These options are silently stripped.
Placeholders. This dialect keeps PostgreSQL's $n positional placeholder style so
it composes with the rest of the ORM's parameter handling. Exasol's own JDBC/WebSocket
drivers use ?; a live driver binding would need to translate accordingly.
Registered with Prorm. dialect: 'exasol' is wired into the Prorm
factory (lazy-loaded), so the connection snippet above works; the tests
additionally construct ExasolDialect directly to exercise the sync builders
without a connection.
Verification status#
SQL-generation verified. All type mapping, CREATE TABLE (DISTRIBUTE BY /
PARTITION BY / IDENTITY / UNIQUE / REFERENCES), MODIFY COLUMN,
IMPORT / EXPORT, INSERT, MERGE-based upsert, UPDATE, DELETE, and SELECT output shown
here is covered by unit tests (tests/dialects/exasol.test.ts,
tests/dialects/exasol-crud.test.ts) that assert on the generated SQL strings. No live
database is required for these.
Needs a live Exasol. Actual execution against a running Exasol instance — driver
binding, $n-to-? placeholder translation, IDENTITY/MERGE runtime semantics, and
IMPORT/EXPORT against a real endpoint — is not exercised by the test suite and has
not been verified here.
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 | exasol |
|---|
| Transactions atomic | yes |
| Savepoints | no — a nested transaction() throws |
| Isolation | up to SERIALIZABLE |
| Transaction modes | read-only yes; deferrable yes; constraint deferral yes |
| RETURNING | not emitted; returning is accepted and ignored |
| Upsert | MERGE INTO … WHEN MATCHED / WHEN NOT MATCHED |
| JSON column | VARCHAR(2000000) (JSONB → VARCHAR(2000000)) |
| Vector column | refused — Exasol has no column type for DataTypes.VECTOR: Exasol has no vector type. |
| Array column | refused — Exasol has no column type for DataTypes.ARRAY: Exasol has no composite types; store JSON in a VARCHAR, or use a child table. |
| WHERE builder | 46 operator keys; $and/$or/$not; $exists: yes; $in subquery: yes; expression operands: yes |
| SQL objects | family unknown: triggers no; procedures no; functions no; materialized views no; sequences no; enums check; RLS no |
| Streaming | queryStream() present; LISTEN present; createExtension() present |
Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16