The hana dialect targets SAP HANA — both on-premise HANA 2.0 and the
continuously-delivered HANA Cloud editions. It is implemented in
hana on top of
SAP's official hdb client: a pure
JavaScript, callback-based driver with no native bindings.HANA is a column-store-first, in-memory HTAP database. The dialect leans into
that: tables are column tables by default, and HANA-native constructs like
UPSERT ... WITH PRIMARY KEY, GENERATED ALWAYS AS IDENTITY, sequences, and
system-versioned temporal tables are all first-class.Overview#
| |
|---|
| Dialect key | 'hana' |
| Driver / library | hdb |
| Default port | 30015 |
| Default user | SYSTEM |
| Identifier quoting | Double quotes ("col"), case-sensitive |
| Default table storage | Column store (CREATE COLUMN TABLE) |
| Pagination | LIMIT n OFFSET m |
| Upsert | Native UPSERT ... WITH PRIMARY KEY |
| Property | Value |
|---|
| Dialect name | hana |
| Extends | nothing — implements Dialect directly |
| Driver | hdb |
| Cloud SDK | none |
| Default port | 30015 |
| Local runtime | self-hosted; no image in this repo |
Install the driver alongside the ORM:The hdb package ships no bundled TypeScript types; the dialect provides its
own minimal ambient shim (src/dialects/hana/hdb.d.ts), so you do not need
@types/hdb.Quick start#
Install#
$ npm install ts-prorm-orm hdb
$ pnpm add ts-prorm-orm hdb
$ yarn add ts-prorm-orm hdb
$ bun add ts-prorm-orm hdb
Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'hana',
host: 'my-instance.hanacloud.ondemand.com',
port: 443,
username: 'DBADMIN',
password: process.env.DB_PASSWORD,
dialectOptions: {
schema: 'APP',
useTLS: true, // required by HANA Cloud
},
});
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 hana has none. Run the work in a single transaction, or restructure it so nothing nests.
Fail fast on a locked row (NOWAIT)#
lock with nowait makes the statement fail at once instead of waiting for another transaction's lock:await db.transaction(async (t) => {
await User.findAll({ where: { id: 1 }, lock: { level: 'UPDATE', nowait: true }, transaction: t });
});
Type mapping for the portable descriptors: STRING → NVARCHAR(255), auto-increment INTEGER → INTEGER, BOOLEAN → BOOLEAN, DATE → TIMESTAMP, UUID → NVARCHAR(36), JSON → NCLOB. The type comparison lines the major engines up side by side.Connection#
Connect through the Prorm constructor with dialect: 'hana'. HANA-specific
options (schema, useTLS, extraOptions) are passed under dialectOptions.import { Prorm } from 'ts-prorm-orm';
const prorm = new Prorm({
dialect: 'hana',
host: 'my-hana-host.hanacloud.ondemand.com',
port: 30015,
username: 'DBADMIN',
password: 'secret',
database: 'HXE', // tenant database name (optional)
dialectOptions: {
schema: 'MY_APP', // default schema for unqualified references
useTLS: true, // required for HANA Cloud
// Any additional raw hdb.createClient() options:
extraOptions: { encrypt: true, sslValidateCertificate: false },
},
});
await prorm.connect();
Defaults if omitted: host: 'localhost', port: 30015, username: 'SYSTEM',
password: ''.The default port 30015 follows SAP's 3<instance>15 convention — the SQL
port of the tenant database's index server on a Multi-Database Container (MDC)
system using instance number 00 (30013 is the system database on the same
instance; instance 01 uses 30115/30117).HANA Cloud vs on-premise#
HANA Cloud connections require TLS (useTLS: true). You can detect which
edition you are connected to — the dialect parses SYS.M_DATABASE.VERSION
(HANA Cloud reports a 4.x-prefixed version; on-premise HANA 2.0 reports
2.x). Reach the live dialect instance with getDialectInstance(). The
edition helpers are HANA-specific (not on the shared Dialect interface), so
cast to reach them:const dialect = prorm.getDialectInstance()!;
const hana = dialect as unknown as {
getEditionInfo(): Promise<{
edition: 'cloud' | 'on-premise' | 'unknown';
version: string;
major?: number;
minor?: number;
revision?: number;
}>;
isCloudEdition(): Promise<boolean>;
};
const info = await hana.getEditionInfo();
if (await hana.isCloudEdition()) {
// branch on Cloud-only feature availability
}
The remaining examples in this guide operate on that same dialect instance
returned by getDialectInstance().Column vs row tables#
HANA's signature feature is the column store. Tables are created as
CREATE COLUMN TABLE by default. To create a legacy row-store table, pass
the HANA-specific columnStore: false flag through TableOptions:// Column table (default) — best for analytics and most workloads
await dialect.createTable('events', {
id: { type: 'BIGINT', autoIncrement: true, primaryKey: true },
payload: { type: 'JSON' },
created_at: { type: 'TIMESTAMP' },
});
// -> CREATE COLUMN TABLE "events" (...)
// Row table — for narrow, high-frequency single-row OLTP access patterns
await dialect.createTable(
'sessions',
{
token: { type: 'NVARCHAR', length: 64, primaryKey: true },
user_id: { type: 'BIGINT' },
},
{ columnStore: false },
);
// -> CREATE ROW TABLE "sessions" (...)
Auto-increment columns emit GENERATED ALWAYS AS IDENTITY (START WITH 1
INCREMENT BY 1) rather than a serial/AUTO_INCREMENT keyword.System-versioned (temporal) tables#
Column tables can opt into HANA's system-versioning for automatic history
tracking. The dialect appends the valid_from/valid_to period columns and a
PERIOD FOR SYSTEM_TIME clause:await dialect.createTable(
'accounts',
{
id: { type: 'BIGINT', primaryKey: true },
balance: { type: 'DECIMAL', precision: 15, scale: 2 },
},
{ systemVersioning: { historyTable: 'accounts_history' } },
);
// -> CREATE COLUMN TABLE "accounts" (..., "valid_from" TIMESTAMP(6) NOT NULL
// GENERATED ALWAYS AS ROW START, "valid_to" TIMESTAMP(6) NOT NULL GENERATED
// ALWAYS AS ROW END, PERIOD FOR SYSTEM_TIME ("valid_from", "valid_to"))
// WITH SYSTEM VERSIONING HISTORY TABLE "accounts_history"
If historyTable is omitted, HANA auto-generates and manages the history table.
System-versioning is column-store only — combining it with columnStore: false
throws. To retrofit an existing table, use alterTableSystemVersioning(), which
issues the required period-column and versioning ALTER TABLE statements in
order (HANA has no single-statement form for this).Data types#
The dialect maps the ORM's generic type keys onto HANA-native types. HANA is a
Unicode-first engine, so string types map to their N-prefixed variants:| ORM type | HANA type |
|---|
STRING / NVARCHAR | NVARCHAR(n) (length: 'max' → NCLOB) |
CHAR | NCHAR(n) |
TEXT / MEDIUMTEXT / LONGTEXT | NCLOB |
SHORTTEXT | SHORTTEXT(n) |
ALPHANUM | ALPHANUM(n) |
INTEGER | TINYINT / SMALLINT / INTEGER / BIGINT (by length 1/2/-/8) |
TINYINT / BIGINT | TINYINT / BIGINT |
FLOAT | REAL |
DOUBLE | DOUBLE |
DECIMAL / NUMERIC / NUMBER | DECIMAL(p, s) |
BOOLEAN / BIT | BOOLEAN |
DATE / DATETIME / TIMESTAMP | TIMESTAMP |
SECONDDATE | SECONDDATE (second-precision timestamp) |
DATEONLY | DATE |
TIME | TIME |
BLOB | BLOB |
BINARY / VARBINARY | VARBINARY(n) |
JSON | NCLOB |
JSONB | BLOB |
UUID | NVARCHAR(36) |
ENUM | NVARCHAR(255) (HANA has no native ENUM) |
GEOMETRY | ST_GEOMETRY (optionally ST_GEOMETRY(srid)) |
ST_POINT | ST_POINT (optionally ST_POINT(srid)) |
Any unrecognized type string is passed through verbatim, so you can supply
HANA-native types directly (e.g. 'SECONDDATE', 'ALPHANUM(10)').Pagination and LIMIT#
HANA uses the standard LIMIT n OFFSET m clause — unlike Oracle/Db2's
OFFSET ... FETCH FIRST .... There is one quirk: HANA does not allow a bare
OFFSET without a LIMIT. When only an offset is supplied, the dialect emits
a sentinel maximum limit (2147483647) to satisfy the parser:const { sql } = dialect.buildSelectQuery({
tableName: 'events',
offset: 100, // no limit given
});
// -> ... LIMIT 2147483647 OFFSET 100
Upserts#
The dialect prefers HANA's native UPSERT ... WITH PRIMARY KEY statement — a
genuine HANA-only construct distinct from the MERGE INTO every other
enterprise dialect uses. With no explicit conflict target, it matches on the
table's primary key:const { sql, values } = dialect.buildUpsertQuery('accounts', {
id: 42,
balance: 100.5,
});
// -> UPSERT "accounts" ("id", "balance") VALUES (?, ?) WITH PRIMARY KEY
When you name explicit conflictFields other than the primary key, the dialect
falls back to a full MERGE INTO ... WHEN MATCHED / WHEN NOT MATCHED statement
(sourced from the single-row DUMMY pseudo-table), since UPSERT ... WITH
PRIMARY KEY can only match on the actual primary key:const { sql } = dialect.buildUpsertQuery(
'accounts',
{ email: 'a@b.com', balance: 0 },
{ conflictFields: ['email'], updateOnDuplicate: ['balance'] },
);
// -> MERGE INTO "accounts" AS target USING (SELECT ? AS "email", ? AS "balance"
// FROM DUMMY) AS source ON target."email" = source."email"
// WHEN MATCHED THEN UPDATE SET "balance" = source."balance"
// WHEN NOT MATCHED THEN INSERT (...) VALUES (...)
Sequences#
HANA has Oracle-style sequences accessed via NEXTVAL/CURRVAL pseudo-columns,
selected from the single-row DUMMY table (HANA's equivalent of Oracle's
DUAL):await dialect.createSequence({ name: 'order_seq', startWith: 1000, incrementBy: 1 });
const next = await dialect.nextSequenceValue('order_seq'); // SELECT "order_seq".NEXTVAL FROM DUMMY
nextSequenceValue() is part of the shared Dialect interface. The HANA
dialect additionally exposes currSequenceValue() (emitting SELECT
"order_seq".CURRVAL FROM DUMMY); reach it by casting the instance, since it is
a HANA-specific extra rather than a shared interface method.Transactions#
hdb has no built-in connection pool, so each transaction is backed by its own
dedicated client connection (with autocommit disabled). This keeps a
transaction's statements from interleaving with unrelated queries on the shared
connection:const tx = await dialect.startTransaction({ isolationLevel: 'SERIALIZABLE' });
try {
await dialect.query('UPDATE "accounts" SET "balance" = "balance" - ? WHERE "id" = ?', {
replacements: [50, 1],
transaction: tx,
});
await dialect.commitTransaction(tx);
} catch (err) {
await dialect.rollbackTransaction(tx);
throw err;
}
The isolation level is applied via SET TRANSACTION ISOLATION LEVEL ... on the
dedicated connection before any statements run.Identifiers and case folding#
This is the single biggest gotcha for developers coming from MySQL/Postgres.
HANA follows the Oracle/Db2 rule: unquoted identifiers are folded to
uppercase. The dialect always quotes identifiers with double quotes, so an
identifier is stored and referenced exactly as written:dialect.escapeId('users'); // -> "users" (stays lowercase)
dialect.quoteTable('users', 'MY_APP'); // -> "MY_APP"."users"
Because of this, catalog lookups (describeTable, showTables, dropTable
with ifExists, etc.) uppercase the name when querying SYS.* views and scope
results to the configured schema (or CURRENT_SCHEMA) — without that scoping,
same-named tables across schemas would collide.Quirks and limitations#
The dialect is honest about features HANA models differently or not at all —
these methods throw a descriptive error rather than emitting invalid SQL:No materialized views. HANA has no CREATE MATERIALIZED VIEW. The nearest
equivalents are Calculation Views (modeled objects, out of scope for a CRUD
ORM) or a manually-refreshed snapshot table. createMaterializedView() and
friends throw.
No CREATE EXTENSION. Extensions are PostgreSQL-specific; the extension
methods throw.
Federation is Smart Data Access, not FDW. HANA's analogue of Postgres
foreign data wrappers is CREATE REMOTE SOURCE / CREATE VIRTUAL TABLE. It
does not map cleanly onto the generic (Postgres-shaped) FDW interface, so the
FDW methods throw with a pointer to Smart Data Access.
No partial (filtered) indexes. createPartialIndex() throws — HANA does
not support a WHERE clause on CREATE INDEX. Full-text (CREATE FULLTEXT
INDEX), spatial (CREATE SPATIAL INDEX), and expression indexes are
supported.
No row-level-security policies. HANA uses Structured/Analytic Privileges
instead; createPolicy(), enableRLS(), etc. throw.
No ALTER ... OWNER TO. Object ownership is managed via privileges/roles,
so changeOwner() throws.
Partitioning: range and hash partitioning are modeled by
createPartitionedTable(). HANA's ROUNDROBIN partitioning is not modeled —
use raw SQL via query(). Attaching/detaching a standalone table as a
partition is not supported (attachPartition/detachPartition throw); use
ADD PARTITION / MERGE PARTITIONS instead.
ALTER TABLE column DDL is parenthesized and can batch multiple columns
in one clause (ADD (...), DROP (...), ALTER (...)), distinct from the
single-column-at-a-time syntax of most dialects. Column renames use a
dedicated RENAME COLUMN t.old TO new statement.
Stored procedures use SQLScript (LANGUAGE SQLSCRIPT). Calling a
procedure returns OUT/INOUT parameters plus zero or more result sets; the
dialect normalizes CALL results into { rows, rowCount, outputParams,
resultSets }.
Recursive CTEs and window/OLAP functions are fully supported via
buildRecursiveCteClause() and buildWindowFunction() (e.g. ROW_NUMBER()
OVER (PARTITION BY ... ORDER BY ...)).
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 | hana |
|---|
| Transactions atomic | yes |
| Savepoints | no — a nested transaction() throws |
| Isolation | up to SERIALIZABLE |
| Transaction modes | read-only no; deferrable no; constraint deferral no |
| RETURNING | refused — HANA does not support a RETURNING clause on INSERT. Re-fetch the row after the insert instead. |
| Upsert | MERGE INTO … WHEN MATCHED / WHEN NOT MATCHED |
| JSON column | NCLOB (JSONB → BLOB) |
| Vector column | REAL_VECTOR(3) |
| Array column | refused — SAP HANA has no ARRAY column type. Store the value as JSON, or model it as a child table. |
| WHERE builder | 32 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 absent; createExtension() present |
Testing#
tests/dialects/hana.test.ts exercises the dialect with a mocked driver, so nothing touches a server.Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16