Overview#
TiDB is a distributed, MySQL-wire-protocol-compatible
NewSQL database: it speaks the MySQL client/server protocol and understands
almost all of MySQL's SQL grammar, while storing data in a horizontally
scalable, distributed key-value layer (TiKV).| Property | Value |
|---|
| Dialect name | tidb |
| Extends | MySQLDialect (mysql) |
| Driver | mysql2 |
| Cloud SDK | none |
| Default port | 4000 |
| Local runtime | prorm db up --dialect tidb (pingcap/tidb:v8.1.1) |
Because of that wire and grammar compatibility, this ORM's TiDBDialect
extends MySQLDialect and reuses MySQL's mysql2 driver verbatim —
connection pooling, identifier escaping, query execution, and most DDL/DML
builders are inherited unchanged. Only the genuine TiDB grammar differences are
overridden or added.Key facts (from src/dialects/tidb/index.ts):Dialect name is 'tidb'. The underlying driver library stays mysql2
(that is the actual driver in use).
Default SQL port is 4000 (MySQL's is 3306). The constructor injects
4000 as a default that an explicit port still overrides:
super({ port: 4000, ...config }).
Storage engines: TiDB parses but effectively ignores MySQL storage-engine
clauses (ENGINE=InnoDB). The engine table option is still emitted for
MySQL source compatibility but has no effect on TiDB.
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: 'tidb',
host: 'localhost',
database: 'test',
username: 'root',
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'tidb',
url: 'tidb://root@localhost:4000/test',
});
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(() => {});
});
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 → 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: 'tidb',
host: '127.0.0.1',
port: 4000, // TiDB default; omit to use the injected default of 4000
username: 'root',
password: '',
database: 'test',
});
The top-level Prorm option is username (mapped internally to the mysql2
driver's user field).
If port is omitted, the dialect defaults it to 4000.
Extra mysql2 driver options (e.g. TLS) can be passed through
dialectOptions.
What differs from MySQL#
TiDBDialect extends MySQLDialect (mysql) and
overrides 18 of its methods (Object.getOwnPropertyNames(TiDBDialect.prototype),
computed by the scratch test); everything else is inherited unchanged.| Area | Overridden |
|---|
| DML builders | buildInsertQuery, buildUpsertQuery, escapeSqlString, formatDefaultValue |
| Types | buildColumnSql, legacyDataTypeSql, modernDataTypeSql |
| DDL and schema | addConstraint, buildAddConstraintSql, buildCreateTableSql, buildDropConstraintSql, buildSplitTableSql, buildTableConstraintSql, createSpatialIndex, createTable, removeConstraint, splitTable |
| Other | legacyNeedsModern |
TiDB-specific features#
AUTO_RANDOM primary keys#
TiDB offers AUTO_RANDOM as an alternative to AUTO_INCREMENT for BIGINT
primary keys. It scatters generated IDs across the key space to avoid the write
hot-spotting that a monotonic AUTO_INCREMENT key causes on a range-partitioned
distributed store.A column flag autoRandom controls it (autoRandom takes precedence over
autoIncrement when both are set):autoRandom: true emits a bare AUTO_RANDOM.
autoRandom: <number> emits AUTO_RANDOM(n), where n is the shard-bit
count.
db.dialect.buildCreateTableSql('t', {
id: { type: 'BIGINT', primaryKey: true, autoRandom: true } as any,
name: { type: 'VARCHAR(100)' },
});
With an explicit shard-bit count (autoRandom: 5):When autoRandom is absent, ordinary AUTO_INCREMENT is emitted:SHARD_ROW_ID_BITS / PRE_SPLIT_REGIONS table options#
For tables that have no integer primary key (or a non-clustered PK), TiDB can
shard the implicit _tidb_rowid and pre-split the table into multiple regions
at creation time so write load is distributed immediately. Both are TiDB
extensions and are not valid MySQL syntax.shardRowIdBits: n → SHARD_ROW_ID_BITS = n
preSplitRegions: n → PRE_SPLIT_REGIONS = n
db.dialect.buildCreateTableSql(
't',
{ id: { type: 'BIGINT', primaryKey: true } },
{ shardRowIdBits: 4, preSplitRegions: 2 } as any,
);
SPLIT TABLE helper#
TiDB's SPLIT TABLE statement pre-splits a table's (or an index's) key range
into N regions, distributing write load before data arrives. It is not standard
MySQL, so it is exposed as dedicated helpers: buildSplitTableSql (pure /
synchronous, returns the SQL string) and splitTable (builds and executes).between: [lower, upper] — inclusive lower/upper bounds. Each element is
emitted inside (...); pass an array for composite keys (comma-joined).
regions: number — number of regions to split into.
index?: string — when set, splits that index's key range
(SPLIT TABLE t INDEX idx BETWEEN ...) instead of the table's row range.
db.dialect.buildSplitTableSql('t', { between: [0, 1000000], regions: 16 });
To split an index's range, pass index:db.dialect.buildSplitTableSql('t', {
index: 'idx_name',
between: ['a', 'z'],
regions: 8,
});
To build and execute in one call:await db.dialect.splitTable('t', { between: [0, 1000000], regions: 16 });
ALTER TABLE ADD/DROP CONSTRAINT#
The base MySQLDialect throws "addConstraint is not supported", but TiDB
fully supports altering a table to add and drop PRIMARY KEY / UNIQUE / FOREIGN
KEY / CHECK constraints, so TiDBDialect implements them
(buildAddConstraintSql, buildDropConstraintSql, addConstraint,
removeConstraint).db.dialect.buildAddConstraintSql('users', {
type: 'UNIQUE',
name: 'uq_email',
fields: ['email'],
});
db.dialect.buildAddConstraintSql('orders', {
type: 'FOREIGN KEY',
name: 'fk_user',
fields: ['user_id'],
references: { table: 'users', fields: ['id'] },
});
Dropping uses the correct clause per constraint type:Inherited MySQL behavior#
TiDB is MySQL-wire compatible, so standard CRUD and most DDL are inherited
from MySQLDialect and behave exactly as documented for the MySQL dialect.
Verified in tests/dialects/tidb-crud.test.ts:Identifiers are backtick-quoted (escapeId('my_col') → `my_col`,
quoteTable('sch.tbl') → `sch`.`tbl`).This is the one CRUD divergence. 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. TiDB rejects that
row-alias syntax, so TiDBDialect overrides buildInsertQuery (with
options.upsert) and buildUpsertQuery to emit the classic, TiDB-compatible
VALUES(col) function form instead:db.dialect.buildUpsertQuery('kv', { k: 'a', v: 1 });
updateOnDuplicate restricts the update set:db.dialect.buildInsertQuery(
'users',
{ id: 1, name: 'Ada', email: 'a@x.io' },
{ upsert: true, updateOnDuplicate: ['name'] },
);
// ... ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)
TiDB, like MySQL, has no RETURNING clause — passing returning to
buildUpsertQuery throws:TiDB does not support the RETURNING clause. Re-fetch the row after the upsert instead.
Type mapping#
Type mapping delegates to the MySQL mapping (getDataTypeSql) for everything
TiDB shares. The following are verified in tests/dialects/tidb-crud.test.ts:| ORM type descriptor | Generated SQL |
|---|
{ key: 'STRING' } | VARCHAR(255) |
{ key: 'STRING', length: 100 } | VARCHAR(100) |
{ key: 'INTEGER' } | INT |
{ key: 'INTEGER', length: 8 } | BIGINT |
{ key: 'BIGINT' } | BIGINT |
{ key: 'BIGINT', unsigned: true } | BIGINT UNSIGNED |
{ key: 'BOOLEAN' } | TINYINT(1) |
{ key: 'DECIMAL', precision: 12, scale: 4 } | DECIMAL(12,4) |
{ key: 'DATE' } | DATETIME |
{ key: 'DATEONLY' } | DATE |
{ key: 'JSON' } | JSON |
{ key: 'JSONB' } | JSON |
{ key: 'UUID' } | CHAR(36) |
{ key: 'ENUM', values: ['a', 'b'] } | ENUM('a','b') |
'TEXT' (raw string) | TEXT |
Rejected: spatial / GIS types#
TiDB implements no spatial data types, so getDataTypeSql rejects them up front
(rather than letting a real TiDB server fail with an opaque parse error). The
following keys throw an error matching /spatial/i:GEOMETRY, GEOGRAPHY, POINT, LINESTRING, POLYGON, MULTIPOINT,
MULTILINESTRING, MULTIPOLYGON, GEOMETRYCOLLECTION.TiDB does not support spatial/GIS data types (got 'POINT'). Store the geometry
as JSON/TEXT or a pair of numeric columns instead.
Caveats / not-yet-verified#
SQL generation is verified; live execution is not. All the SQL shown here
is produced and asserted by the pure/synchronous build* builders in
tests/dialects/tidb.test.ts and tests/dialects/tidb-crud.test.ts, which
run without any database connection. The actual runtime behavior of these
statements against a live TiDB cluster (e.g. that SPLIT TABLE,
AUTO_RANDOM, SHARD_ROW_ID_BITS, and PRE_SPLIT_REGIONS execute and
produce the intended region splits) has not been exercised end-to-end in
this test suite and would require a running TiDB server to confirm.
ENGINE= is emitted but ignored. The engine table option is passed
through for MySQL source compatibility; TiDB parses but ignores it, since all
data lives in TiKV.
SHARD_ROW_ID_BITS / AUTO_RANDOM interaction. These options target
distinct table shapes on a live cluster (SHARD_ROW_ID_BITS applies to tables
without a clustered integer PK; AUTO_RANDOM applies to a BIGINT PK). This
dialect emits whatever is requested and does not validate that combination —
a real TiDB server enforces the actual constraints.
Inherited MySQL builders are assumed to behave as in the MySQL dialect;
the TiDB CRUD tests re-verify the common ones (INSERT/SELECT/UPDATE/DELETE) to
guard against regressions, but the full MySQL surface is documented under the
MySQL dialect.
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 | tidb |
|---|
| 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 mysql: triggers yes; procedures yes; functions multiStatement; materialized views emulated (procedure); sequences no; enums inline; RLS no |
| Streaming | queryStream() present; LISTEN absent; createExtension() present |
Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16