Writing a custom dialect
A dialect adds an engine Turbine does not ship. It is one object, typed Dialect, that builds every piece of SQL that differs between engines. The shipped SQLite, MySQL and SQL Server engines are dialects too.
import {
postgresDialect,
type Dialect,
type DialectName,
type EngineCapabilities,
type ResultStrategy,
type BuiltStatement,
type InsertStatementInput,
type BulkInsertStatementInput,
type UpsertStatementInput,
type ColumnTypeInput,
type ColumnDefinitionInput,
type CreateTableStatementInput,
type CreateIndexStatementInput,
type StreamableConnection,
type DialectIntrospector,
type DialectIntrospectOptions,
} from 'turbine-orm';Start from postgresDialect#
Spread the PostgreSQL dialect and override what differs:
import { postgresDialect, TurbineClient, type Dialect } from 'turbine-orm';
export const myDialect: Dialect = {
...postgresDialect,
name: 'myengine',
capabilities: {
...postgresDialect.capabilities,
distinctOn: false,
positionalJson: false,
nullsSortHigh: false,
readOnlyTransactions: false,
backendCancel: false,
abortsTransactionOnError: false,
statementSyntax: null,
// ...every other key your engine answers differently
},
paramPlaceholder: (i) => `@p${i}`,
supportsVector: false,
supportsListenNotify: false,
};
const db = new TurbineClient({ pool: myPoolShim, dialect: myDialect }, SCHEMA);The pool shim implements PgCompatPool: query(text, values), connect() and end(). That is the whole driver contract.
Engine capabilities: what the engine is, not what it is called#
capabilities (type EngineCapabilities) says what the engine can do. Turbine reads these keys rather than dialect.name. The object is required: TurbineClient refuses a dialect without one with ValidationError (TURBINE_E003).
A spread copies PostgreSQL's answers under your new name. Go through every key: a wrong true becomes a failing query at runtime, while a correct false becomes a clear TURBINE_E017. The shipped sqliteDialect, mysqlDialect and mssqlDialect (from their subpaths) are worked examples.
Every shipped engine's value, generated from its dialect object and checked against it on every pull request (✓ is true, ✗ is false, and a string value is shown without its quotes):
| Key | PostgreSQL | SQLite | MySQL | SQL Server | PowDB |
|---|---|---|---|---|---|
distinctOnfindMany distinct and groupBy distinctOn | ✓ | ✗ | ✗ | ✗ | ✗ |
positionalJsonPositional relation encoding (the default where true) | ✓ | ✗ | ✗ | ✗ | ✗ |
nullsOrderingClauseNULLS FIRST / NULLS LAST placement | ✓ | ✓ | ✗ | ✗ | ✗ |
nullsSortHighNULL sorts after every value ascending | ✓ | ✗ | ✗ | ✗ | ✗ |
insensitiveInListmode: 'insensitive' on in / notIn | ✓ | ✗ | ✗ | ✗ | ✗ |
jsonDocumentEqualityWhole-document JSON comparison: 'jsonb', 'exact' or 'text' (refused) | jsonb | text | exact | text | text |
jsonExtractTypedJSON path extraction returns typed values | ✗ | ✓ | ✗ | ✗ | ✗ |
jsonExactNumericCastJSON path ranges cast to exact numeric | ✓ | ✗ | ✗ | ✗ | ✗ |
numericTextComparisonNumbers compared with text bind as text | ✗ | ✗ | ✓ | ✗ | ✗ |
enforcesForeignKeysForeign keys are always enforced | ✓ | ✗ | ✓ | ✓ | ✗ |
partitionLimitPushdownBatched loader pushes per-parent limit into SQL | ✓ | ✗ | ✗ | ✗ | ✗ |
utcTimestampRewriteutcTimestamps rewrite of zone-less timestamps | ✓ | ✗ | ✗ | ✗ | ✗ |
paramTypeCastsUpdate-expression parameters carry ::type casts | ✓ | ✗ | ✗ | ✗ | ✗ |
namedEnumTypesEnums are named types (CREATE TYPE ... AS ENUM) | ✓ | ✗ | ✗ | ✗ | ✗ |
structuredValueColumnsColumns that refuse an object or array | pg- | scalars | scalars- | scalars | scalars |
maxBindParametersParameters per statement | 65,535 | 32,766 | 65,535 | 2,098 | no limit |
maxInsertRowsRows per INSERT ... VALUES | no limit | no limit | no limit | 1,000 | no limit |
chunkBindParametersParameters per createMany chunk | 65,535 | 250 | 65,535 | 2,098 | no limit |
upsertConflictTargetNamed conflict target, or 'any-unique-key' | explicit | explicit | any- | explicit | explicit |
setClauseOrder'left-to-right' when a SET reads earlier assignments | snapshot | snapshot | left- | snapshot | snapshot |
reselectLocksRowsReselect reads take FOR UPDATE | ✗ | ✗ | ✓ | ✗ | ✗ |
integerDivisionTruncating integer division operator | / | / | DIV | / | / |
updateExpressionsexpr and col() in update data | ✓ | ✓ | ✓ | ✓ | ✗ |
bulkUpsertAppliesRepeatedKeysMulti-row upsert merges collation-equal keys | ✗ | ✓ | ✓ | ✗ | ✗ |
skipDuplicatesCountsSkippedskipDuplicates counts skipped rows as affected | ✗ | ✗ | ✓ | ✗ | ✗ |
readOnlyTransactions$transaction({ readOnly: true }) | ✓ | ✗ | ✓ | ✗ | ✗ |
connectionParametersServer settings as connection parameters | ✓ | ✗ | ✗ | ✗ | ✗ |
backendCancelTimed-out statements are cancelled on the server | ✓ | ✗ | ✗ | ✗ | ✗ |
idleTransactionTimeoutServer-side idle limit for a transaction | ✓ | ✗ | ✗ | ✗ | ✗ |
abortsTransactionOnErrorA failed statement aborts the transaction | ✓ | ✗ | ✗ | ✗ | ✗ |
connectionDriver'pg-compatible', 'engine-driver' or 'none' | pg- | engine- | engine- | engine- | none |
preparesEveryStatementEvery statement is prepared under a server-wide cap | ✗ | ✗ | ✓ | ✗ | ✗ |
statementSyntaxLexer for screening raw SQL (null: none) | postgresql | sqlite | mysql | mssql | none |
inlineForeignKeysInline REFERENCES creates a foreign key | ✓ | ✓ | ✗ | ✓ | ✗ |
unboundedTextKeysUnbounded text columns can be keys | ✓ | ✓ | ✗ | ✗ | ✓ |
Two renderings still follow the name. expr.now(), expr.greatest() and expr.least() throw TURBINE_E017 under any name other than postgresql, mysql, sqlite or mssql. schemaToSQL writes column DEFAULTs in PostgreSQL's spelling under any other name.
Feature flags#
Feature flags sit at the top level of the dialect. Most gate a feature: when off, using it throws UnsupportedFeatureError (TURBINE_E017) instead of emitting SQL that cannot work. A few choose a SQL shape instead. jsonPathSupport (required) is 'native', 'function' or 'limited'.
| Flag | Required | When false or absent |
|---|---|---|
supportsReturning | yes | No RETURNING; set resultStrategy to match |
supportsILike | yes | buildInsensitiveLike supplies the equivalent |
supportsVector | yes | pgvector filters and ordering throw |
supportsJsonContains | yes | Pathless JSON contains / equals throw |
supportsListenNotify | yes | $listen / $notify throw |
supportsRLS | yes | sessionContext throws |
supportsAdvisoryLock | yes | Migration locking needs a DatabaseAdapter |
aggSupportsInlineOrderBy | yes | Ordered to-many relations use a subquery |
supportsUpsertUpdateWhere | no | An upsert under a global filter looks the row up first. Set true only if buildUpsertStatement emits updateWhere. |
supportsFullTextSearch | no | search throws |
supportsArrayColumns | no | has / hasEvery / hasSome / isEmpty throw |
supportsLateralJoin | no | plan: 'lateral' pick-row ordering throws |
supportsPlanCacheMode | no | planCacheMode and forceCustomPlan throw |
supportsPercentileAggregates | no | _percentile throws |
supportsBulkUpsert | no | createMany({ onConflict }) throws |
rowLockModes | no | Every lock throws. Set the allowed strengths. |
explainQuery | no | explain() throws. Set { prefix }. |
inlineLimitOffset | no | LIMIT / OFFSET are bound (MySQL inlines validated integers) |
An absent optional flag means off, but a spread is not absence: it carries PostgreSQL's value. Set every flag your engine answers differently as its own property.
resultStrategy#
type ResultStrategy = 'returning' | 'output' | 'reselect';| Strategy | How a write returns its row | Used by |
|---|---|---|
'returning' | Trailing RETURNING * | PostgreSQL, SQLite 3.35+ |
'output' | OUTPUT INSERTED.* / MERGE in the same statement | SQL Server |
'reselect' | The write, then a SELECT by key or where | MySQL |
A reselect is a second statement. It cannot return rows it has no key for, which is why a plain createMany on MySQL returns [].
Required methods#
- Values:
paramPlaceholder(index),quoteIdentifier(name),escapeStringLiteral(value). Return a numbered placeholder ($1,:p1,@p1), not?: parameters are pushed in build order, which is not always text order. - JSON nesting:
buildJsonObject,buildJsonArrayAgg,wrapJsonSubresult,emptyJsonArrayLiteral,nullJsonLiteral,buildJsonContains,buildJsonPathExtract. An empty to-many must come back[]and a missing to-onenull. - Statements:
buildReturningClause,buildInsertStatement,buildBulkInsertStatement,buildUpsertStatement,buildInsensitiveLike,buildCorrelation. - DDL:
buildColumnType,buildColumnDefinition,buildPrimaryKeyConstraint,buildCreateTableStatement,buildCreateIndexStatement. - Migration tracking:
buildMigrationTrackingTable,buildMigrationSelectApplied,buildMigrationUpdateChecksum,buildMigrationInsertApplied,buildMigrationDeleteApplied. - Transactions:
beginStatement,commitStatement,rollbackStatement,savepointStatement,releaseSavepointStatement,rollbackToSavepointStatement,buildSetSessionConfig. - Streaming:
openStream(connection, sql, params, batchSize), an async generator of row batches. Only PostgreSQL's runs a server-side cursor; the other shipped dialects load the result, then yield it in batches.
Optional hooks#
An omitted hook falls back to the PostgreSQL behavior.
| Hook | Override it when |
|---|---|
castAggregate | There is no :: cast (SQLite) |
buildInClause + inClauseParam | There are no array parameters. Override both or neither. |
escapeLikePattern + likeEscapeCharacter | LIKE needs other escaping (SQL Server's [, MySQL's !) |
buildLimitOffset, buildUpdateStatement, buildDeleteStatement | The statement shape differs (SQL Server's OFFSET ... FETCH and mid-statement OUTPUT) |
buildRelationSubquery | JSON aggregation cannot use the primitives (SQL Server's FOR JSON PATH) |
buildEnumColumn | Enums are not named types |
checkTextWrite | The engine stores bad text in a numeric column instead of refusing it (SQLite) |
jsonWireRule, decodeColumnValue, averageArgument | Values differ inside JSON, from storage, or from AVG |
buildJsonPathEquals | JSON { path, equals } should compare typed values |
buildBulkUpsertStatement, buildJsonArray, buildPartitionLimit | You turn on supportsBulkUpsert, positionalJson or partitionLimitPushdown |
buildDefaultValuesInsertStatement, arrayType, typeToTypeScript, introspector | All-defaults inserts, array casts, type mapping or catalog reads differ |
DialectMigrator is deprecated and unused. Migration locking lives in a DatabaseAdapter.
Checklist#
- Set every
capabilitieskey and feature flag from measured behavior. - Pick the
resultStrategythat matches the engine, and document its limits if it is not'returning'. - Test nested
withat depth, an ordered to-many with a per-relationlimit, a composite-key relation, an empty to-many ([]) and a missing to-one (null). - Check that
quoteIdentifiersurvives identifiers containing quote characters. - Confirm every value reaches the database as a bound parameter.
See also#
- Database Engines: the engines Turbine ships and their capability matrix.
- Database Compatibility: PostgreSQL-wire databases, which need no dialect.
- The
turbine-orm/powdbmodule: the engine whose queries bypass the dialect. - Typed Errors:
UnsupportedFeatureErrorand the rest of the code table.