Overview#
OceanBase is a distributed relational database with
two compatibility modes — MySQL and Oracle — chosen per tenant when the
tenant is created. The MySQL mode is the mainstream one, and it is what this
dialect models.| Property | Value |
|---|
| Dialect name | oceanbase |
| Extends | MySQLDialect (mysql) |
| Driver | mysql2 |
| Cloud SDK | none |
| Default port | 2881 |
| Local runtime | self-hosted; no image in this repo |
Because MySQL mode speaks the MySQL client/server protocol, OceanBaseDialect
extends MySQLDialect and reuses MySQL's mysql2 driver verbatim —
connection pooling, identifier escaping, query execution, streaming, and most
DDL/DML builders are inherited unchanged. Only the grammar and capabilities that
genuinely differ are overridden.Oracle mode is not covered. A tenant created in Oracle compatibility mode
speaks a different SQL grammar entirely (ROWNUM, DUAL, PL/SQL packages,
sequence-based keys). This dialect will emit MySQL-mode SQL against it and the
server will reject it. There is no oceanbase-oracle dialect today.
Key facts (from src/dialects/oceanbase/index.ts):Dialect name is 'oceanbase'. The underlying driver library stays mysql2.
Default SQL port is 2881 — an observer's own MySQL port. MySQL's 3306
reaches nothing on an OceanBase cluster. Connections through obproxy (ODP)
use 2883; pass port: 2883 for those.
A connection selects its tenant through the username, not a separate
field. See below.
Quick start#
Install#
$ npm install ts-prorm-orm mysql2
$ pnpm add ts-prorm-orm mysql2
$ yarn add ts-prorm-orm mysql2
$ bun add ts-prorm-orm mysql2
mysql2 is the same driver the mysql dialect uses.Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'oceanbase',
host: 'localhost',
database: 'test',
username: 'app',
password: process.env.DB_PASSWORD,
dialectOptions: {
tenant: 'app_tenant', // sent as app@app_tenant
},
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'oceanbase',
url: 'oceanbase://app:secret@localhost:2881/test',
dialectOptions: {
tenant: 'app_tenant', // sent as app@app_tenant
},
});
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' });
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#
[!NOTE] No JSON operator in the WHERE builder
DataTypes.JSON() maps to JSON, but this dialect's WHERE builder refuses Op.contains on it: Cannot render WHERE condition at meta.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.
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(() => {});
});
Type mapping for the portable descriptors: STRING → VARCHAR(255), auto-increment INTEGER → INT, BOOLEAN → TINYINT(1), DATE → DATETIME, UUID → CHAR(36), 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: 'oceanbase',
host: '127.0.0.1',
port: 2881, // observer direct; use 2883 through obproxy
username: 'app',
password: '',
database: 'test',
dialectOptions: {
tenant: 'prod_tenant', // composed into the username as app@prod_tenant
cluster: 'obcluster', // optional; appended as #obcluster
},
});
Tenanted usernames#
OceanBase has no tenant field on the wire. The tenant is selected by the
username, in one of two documented forms:user@tenant#cluster
cluster:tenant:user
Omitting the tenant lands the connection in the sys tenant, which is the
cluster's own administrative tenant and not where application schemas live —
a mistake that shows up as "table doesn't exist" rather than as a login error.Passing tenant (and optionally cluster) composes the username for you:OceanBaseDialect.buildTenantUsername('app', 'prod_tenant', 'obcluster');
// 'app@prod_tenant#obcluster'
A username that already carries an @ or a : is left untouched — you have
spelled the tenant out yourself, and re-appending would produce
root@sys@prod_tenant, which authenticates as nothing.The cluster suffix is required by obproxy when it fronts more than one
cluster, and ignored when connecting straight to an observer.What differs from MySQL#
OceanBaseDialect extends MySQLDialect (mysql) and
overrides 15 of its methods (Object.getOwnPropertyNames(OceanBaseDialect.prototype),
computed by the scratch test); everything else is inherited unchanged.| Area | Overridden |
|---|
| Connection and execution | startTransaction |
| DML builders | buildInsertQuery, buildUpsertQuery, escapeSqlString, formatDefaultValue |
| Types | buildColumnSql |
| DDL and schema | buildCreateTableGroupSql, buildCreateTableSql, buildTableConstraintSql, buildTableOptionsSql, createTable, createTableGroup, dropTableGroup |
| Other | buildDuplicateKeyUpdateClause, legacyNeedsModern |
OceanBase-specific features#
Distributed table options#
CREATE TABLE accepts a set of options controlling how the table is replicated,
placed and stored across the cluster. None of these are valid MySQL syntax.| Option | Emitted as | What it controls |
|---|
replicaNum | REPLICA_NUM = n | Number of replicas held for this table |
duplicateScope | DUPLICATE_SCOPE = 'cluster' | Broadcast the table to every zone so joins stay local |
locality | LOCALITY = 'F@zone1,F@zone2' | Replica type and placement |
primaryZone | PRIMARY_ZONE = 'zone1' | Zone holding the leader replica |
tablegroup | TABLEGROUP = name | Co-locate this table's partitions with the group's |
blockSize | BLOCK_SIZE = 16384 | Microblock size in bytes |
compression | COMPRESSION = 'zstd_1.0' | Storage compression algorithm |
useBloomFilter | USE_BLOOM_FILTER = TRUE | Bloom filter for point lookups |
tabletSize | TABLET_SIZE = 134217728 | Bytes per tablet before it splits |
db.dialect.buildCreateTableSql(
'orders',
{ id: { type: 'BIGINT', primaryKey: true } },
{ replicaNum: 3, duplicateScope: 'cluster', primaryZone: 'zone1' },
);
A table that asks for none of these produces exactly the DDL the MySQL dialect
would produce, and createTable() delegates straight to the MySQL
implementation in that case, so the common path keeps the base dialect's full
post-creation handling.Tablegroups#
A tablegroup binds tables whose matching partitions should live on the same
server, so a join between them never crosses the network.db.dialect.buildCreateTableGroupSql('tg_orders', { sharding: 'PARTITION' });
SHARDING takes NONE, PARTITION or ADAPTIVE. PARTITION requires member
tables to share a partition scheme. createTableGroup() and dropTableGroup()
build and execute; buildCreateTableGroupSql() is pure/synchronous.Divergences from MySQL#
Isolation levels — only two exist#
OceanBase's MySQL mode implements READ COMMITTED (the default) and
REPEATABLE READ. READ UNCOMMITTED and SERIALIZABLE are not available.The base MySQL dialect passes whatever level it is given straight into
SET TRANSACTION ISOLATION LEVEL, which would either fail deep inside the
driver or silently run the transaction at a level the caller did not choose.
A silently downgraded isolation level is a correctness bug nobody can see, so
startTransaction() rejects the unsupported ones by name:OceanBase (MySQL mode) does not support the SERIALIZABLE isolation level.
Supported levels are READ COMMITTED and REPEATABLE READ.
MySQL 8.0.19 introduced the row-alias INSERT form (INSERT ... VALUES (...) AS
new_vals ON DUPLICATE KEY UPDATE col = new_vals.col), which the base
MySQLDialect emits. OceanBase's support for that form is not documented
either way, and the classic VALUES(col) function form is accepted by both
engines, so OceanBaseDialect overrides buildInsertQuery (with
options.upsert) and buildUpsertQuery to emit the classic form:db.dialect.buildUpsertQuery('kv', { k: 'a', v: 1 });
This is a deliberately conservative choice, not a claim that the row-alias
form is rejected. If you confirm OceanBase accepts it, the classic form still
works — it is only deprecated in upstream MySQL, not removed.OceanBase's MySQL mode has no RETURNING clause; passing returning to
buildUpsertQuery throws.AUTO_INCREMENT is neither gapless nor, by default, monotonic#
This changes no SQL, but it changes what the SQL means, and it is the OceanBase
behaviour most likely to break an application ported from MySQL.In NOORDER mode (the default) each observer caches a disjoint range of the
sequence — [1,100] on one node, [101,200] on another — so generated values
are globally unique but arrive out of order and with visible jumps between
ranges. ORDER mode restores monotonicity by routing allocation through a
single node, at that node's cost. Releases before v4.0 guarantee neither
contiguity nor global ordering, only that values increase within a partition.Any code that treats an auto-increment key as an insertion order — "the highest
id is the newest row", cursor pagination on the primary key, gap detection — is
wrong on OceanBase. The relevant tenant variables are auto_increment_offset,
auto_increment_increment and auto_increment_cache_size.ENGINE= is parsed and ignored#
OceanBase has one LSM-tree storage engine. There is no InnoDB and no pluggable
engine. The engine table option is still emitted so DDL carried over from
MySQL round-trips, but it has no effect.Deliberately not rejected#
Two things this dialect could have thrown on, and does not, because the answer
depends on the server version:Spatial / GIS types. Unlike TiDB, OceanBase does support GEOMETRY,
POINT, POLYGON and the ST_* functions — added in 4.1 and expanded in
4.2.2. Spatial types are therefore passed straight through to the MySQL
type mapping. Whether SPATIAL INDEX itself can be created is not documented
either way, so it is not rejected either.
FULLTEXT and HASH indexes. Documented as unsupported in older releases and
as available from 4.2.2. A hard error keyed to the wrong version would break a
cluster that supports them perfectly well, so neither is rejected. If your
cluster predates them, the server will say so.
Inherited MySQL behavior#
Standard CRUD and most DDL are inherited from MySQLDialect and behave exactly
as documented for the MySQL dialect — identifiers are backtick-quoted, SELECT
/ INSERT / UPDATE / DELETE builders, JSON helpers, FOR UPDATE /
LOCK IN SHARE MODE, and the full type mapping (see the
MySQL dialect page). Foreign keys, CHECK constraints, generated
columns, triggers and stored procedures are all supported by OceanBase, so the
inherited builders for those are left alone. OceanBase's partitioning is a
superset of MySQL's (it adds non-template sub-partitioning and more
sub-partition methods), so inherited partition DDL is valid as-is.Caveats / not-yet-verified#
SQL generation is verified; live execution is not. Every SQL snippet on
this page is the exact output of a builder asserted in
tests/dialects/oceanbase.test.ts, which runs without any database
connection. None of it has been executed against a live OceanBase cluster
in this repository.
The row-alias upsert question is open. As above: the classic form is
emitted because it is known-good on both engines, not because the row-alias
form is known-bad.
Reserved words have not been diffed. OceanBase publishes a
reserved-keywords list for MySQL mode; it was not reachable while this dialect
was written, so no keyword-specific quoting differences are modelled. All
identifiers are backtick-quoted by the inherited escaping, which covers the
common case.
SPATIAL INDEX support is unconfirmed. Spatial types are confirmed from
4.1; whether an index can be built on one is not.
Introspection helpers inherited from MySQL (showTables, describeTable,
getCreateTable, showIndexes, showConstraints, and the
INFORMATION_SCHEMA-based partition helpers) assume MySQL system-catalog
shapes and have not been checked against OceanBase's catalogs.
Trigger support is version-sensitive. Triggers are supported, and 4.2.1
LTS added PRECEDES/FOLLOWS ordering, but there is at least one community
report of trigger creation failing on 4.2.1.2 specifically.
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 | oceanbase |
|---|
| Transactions atomic | yes |
| Savepoints | yes |
| Isolation | up to SERIALIZABLE |
| Transaction modes | read-only yes; deferrable no; constraint deferral no |
| RETURNING | not emitted; returning is accepted and ignored |
| Upsert | INSERT … ON DUPLICATE KEY UPDATE |
| JSON column | JSON (JSONB → JSON) |
| Vector column | VECTOR(3) |
| Array column | VARCHAR(255) |
| WHERE builder | 28 operator keys plus the $json: prefix; $and/$or/$not; $exists: no; $in subquery: no; expression operands: no |
| 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 |
Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16