-
Notifications
You must be signed in to change notification settings - Fork 2
OQL Translator
SQL and NoSQL query translators for OQL.
- Overview
- Installation
- Supported Databases
- Translator API
- SQL Translators
- NoSQL Translators
- Parameters
- Usage Examples
- JSON Path Filtering
The translator module converts OQL query objects into native database queries. Each translator implements database-specific syntax, operators, and features while maintaining a consistent API.
Features:
- Parameter binding for SQL injection prevention
- Dialect-specific optimizations
- Comprehensive operator support
- Expression and aggregate translation
- DDL operation support
Deno:
deno add @tundralibs/oqlBun:
bunx jsr add @tundralibs/oqlNode.js:
npx jsr add @tundralibs/oql| Database | Translator | Parameter Style | Features |
|---|---|---|---|
| PostgreSQL | PostgresTranslator |
:p_0:, :p_1: |
Full support, JSON path filters, arrays |
| MariaDB | MariaTranslator |
:p_0:, :p_1: |
Full support, MariaDB-specific functions |
| SQLite | SQLiteTranslator |
:p_0:, :p_1: |
Full support, SQLite-specific syntax |
| MongoDB | MongoTranslator |
N/A | Aggregation pipeline, CRUD operations |
Note on parameters. Every SQL translator (including
PostgresTranslator) emits the engine-compat named placeholder form:p_0:, :p_1:, …with param keysp_0,p_1, … . The numbered$1, $2, …form Postgres ultimately uses is produced later by the@tundralibs/driversPostgres engine, outside OQL — the translator itself never emits$N.
The SQL translators (PostgresTranslator, MariaTranslator,
SQLiteTranslator) extend AbstractTranslator and return TranslatedQuery
(or TranslatedQuery[] for multi-statement DDL). The MongoTranslator does
not extend AbstractTranslator — it is a standalone class that mirrors
the same public-method surface but returns MongoAction union variants
instead of TranslatedQuery (see NoSQL Translators).
AbstractTranslator provides these methods:
abstract class AbstractTranslator {
// DML methods — one per query-type discriminator
select(query: Query<'SELECT'>): TranslatedQuery;
insert(query: Query<'INSERT'>): TranslatedQuery;
insertQuery(query: Query<'INSERT_FROM_QUERY'>): TranslatedQuery; // INSERT ... SELECT ...
update(query: Query<'UPDATE'>): TranslatedQuery;
delete(query: Query<'DELETE'>): TranslatedQuery;
upsert(query: Query<'UPSERT'>): TranslatedQuery;
count(query: Query<'COUNT'>): TranslatedQuery;
// DDL methods
createSchema(query: Query<'CREATE_SCHEMA'>): TranslatedQuery;
dropSchema(query: Query<'DROP_SCHEMA'>): TranslatedQuery;
createTable(query: Query<'CREATE_TABLE'>): TranslatedQuery[];
dropTable(query: Query<'DROP_TABLE'>): TranslatedQuery;
alterTable(query: Query<'ALTER_TABLE'>): TranslatedQuery[];
truncate(query: Query<'TRUNCATE'>): TranslatedQuery;
createIndex(query: Query<'CREATE_INDEX'>): TranslatedQuery;
dropIndex(query: Query<'DROP_INDEX'>): TranslatedQuery;
createView(query: Query<'CREATE_VIEW'>): TranslatedQuery;
dropView(query: Query<'DROP_VIEW'>): TranslatedQuery;
alterView(query: Query<'ALTER_VIEW'>): TranslatedQuery[];
refreshMaterializedView(
query: Query<'REFRESH_MATERIALIZED_VIEW'>,
): TranslatedQuery;
}Method naming. Each public method maps 1:1 to a Query<'…'>
discriminator: Query<'INSERT'> → insert(), Query<'INSERT_FROM_QUERY'>
→ insertQuery() (produces INSERT INTO … SELECT … FROM …), and so
on. Some DDL operations return TranslatedQuery[] because a dialect
may emit the table plus separate index/constraint statements that
can't go inline.
The SQL translators return this structure (the MongoTranslator returns
MongoAction instead — see NoSQL Translators):
type TranslatedQuery = {
sql: string;
params: Record<string, unknown>;
};PostgreSQL-specific translator with full feature support.
import type { Query } from '@tundralibs/oql';
import { PostgresTranslator } from '@tundralibs/oql/translator';
const translator = new PostgresTranslator();
const query: Query<'SELECT'> = {
type: 'SELECT',
table: 'users',
columns: ['id', 'email', 'age'],
projection: { '@id': true, '@email': true },
where: { '@age': { $gte: 18 } },
};
const { sql, params } = translator.select(query);
// sql: SELECT "id" AS "id", "email" AS "email" FROM "users" WHERE "age" >= :p_0:
// params: { p_0: 18 }Features:
- Double-quote identifiers
- Named parameters (:p_0:, :p_1:, ...) — the Postgres engine rewrites these to $1, $2, ... downstream
- JSON path comparisons (
@col.@keyfilter keys →->>/#>>text extraction — see JSON Path Filtering) - Array operators
- RETURNING clause support
Not yet supported (see ROADMAP): full-text search and
window functions (ROW_NUMBER/RANK/PARTITION BY) — the target database
has them, but OQL does not emit them.
MariaDB/MySQL-specific translator.
import type { Query } from '@tundralibs/oql';
import { MariaTranslator } from '@tundralibs/oql/translator';
declare const query: Query<'SELECT'>; // the same query as above
const translator = new MariaTranslator();
const { sql, params } = translator.select(query);
// sql: SELECT `id` AS `id`, `email` AS `email` FROM `users` WHERE `age` >= :p_0:
// params: { p_0: 18 }Features:
- Backtick identifiers
- Named parameters (:p_0:, :p_1:, ...)
- MariaDB-specific functions
- JSON path comparisons (
@col.@keyfilter keys →JSON_UNQUOTE(JSON_EXTRACT(…))— MariaDB has no MySQL-style->>) - LIMIT/OFFSET support
- ON DUPLICATE KEY UPDATE for UPSERT
SQLite-specific translator.
import type { Query } from '@tundralibs/oql';
import { SQLiteTranslator } from '@tundralibs/oql/translator';
declare const query: Query<'SELECT'>; // the same query as above
const translator = new SQLiteTranslator();
const { sql, params } = translator.select(query);
// sql: SELECT "id" AS "id", "email" AS "email" FROM "users" WHERE "age" >= :p_0:
// params: { p_0: 18 }Features:
- Double-quote identifiers
- Named parameters (:p_0:, :p_1:, ...)
- SQLite-specific functions
- JSON path comparisons (
@col.@keyfilter keys →json_extract(), SQLite 3.38+) - Limited DDL operations
- No schema object (
CREATE_SCHEMA/DROP_SCHEMAthrow);schemaon every other query is folded into the name as a<schema>_<name>prefix
Translates OQL to MongoDB operations.
import type { Query } from '@tundralibs/oql';
import { MongoTranslator } from '@tundralibs/oql/translator';
import type { MongoAction } from '@tundralibs/oql/translator';
const translator = new MongoTranslator();
const query: Query<'SELECT'> = {
type: 'SELECT',
table: 'users',
columns: ['id', 'email', 'age'],
projection: { '@id': true, '@email': true },
where: { '@age': { $gte: 18 } },
};
const action = translator.select(query);
// {
// sql: 'find',
// params: {
// collection: 'users',
// filter: { age: { $gte: 18 } },
// options: { projection: { id: 1, email: 1 } }
// }
// }MongoTranslator methods return a MongoAction — a discriminated union
keyed by the sql literal ('find', 'aggregate', 'insert', …), each
carrying its own params shape. Drivers switch (action.sql) and use
action.params directly. Unlike the SQL translators, it does not extend
AbstractTranslator and does not return TranslatedQuery.
Features:
- Converts to MongoDB aggregation pipeline
- Maps OQL operators to MongoDB operators
- Handles joins via $lookup
- Supports aggregates via $group
- Converts expressions to $project stages
- Evaluates expressions in writes: an UPDATE or UPSERT holding one becomes
a pipeline update, and an INSERT computes clock expressions and
UUIDitself (see Compatibility)
Join
typeis not honoured.__buildLookupreads a join'stable,columns, andon, but never itstype—INNER,LEFT,RIGHT, andFULLall emit the identical$lookupstage (no$unwind, no post-filter), which is uniformly LEFT-outer: an unmatched local document still comes through with an empty array for the join alias.RIGHTis not emulated by reversing collections andFULLdoes not throw — both silently behave likeLEFT. See the Compatibility Matrix for the full breakdown before relying on jointypeon Mongo.
Pipeline-ordering guarantees:
- A
where$matchis emitted only after the stages that materialise every field it references — after$lookupwhen it references a joined field in either key or value position (e.g.{ '@createdAt': { $gt: '@Author.@joinedAt' } }), and after the expressions$addFieldswhen it references a declared expression alias. A pure primary-table filter still gets the efficient early$match. (Matching a not-yet-materialised field silently compares againstmissingand returns wrong rows.) -
insertQuery(INSERT … SELECT) emits an aggregation over the source collection ending in$merge(append) — never$out, which would replace the whole target collection. The source SELECT's WHERE / projection / limit / sort are preserved in the pipeline.
Mongo Action Types:
type MongoAction =
| MongoFindAction
| MongoAggregateAction
| MongoInsertAction
| MongoUpdateAction
| MongoBulkWriteAction
| MongoDeleteAction
| MongoCountAction
| MongoCreateCollectionAction
| MongoCreateIndexAction
| MongoDropIndexAction
| MongoDropAction
| MongoRenameCollectionAction
| MongoCreateViewAction
| MongoDropDatabaseAction
| MongoNoopAction;MongoBulkUpsertOp is also exported, but it is not a MongoAction
member — it is the per-row sub-shape ({ filter, update }) carried inside a
MongoBulkWriteAction's params.ops array.
The Parameters class handles parameter binding:
import { Parameters } from '@tundralibs/oql/translator';
const params = new Parameters();
// Add parameters — returns the generated param NAME (not a placeholder).
// Same value added twice returns the same name (dedup).
const name1 = params.add(18); // 'p_0'
const name2 = params.add('active'); // 'p_1'
// Number of registered params
params.size; // 2
// Snapshot of all params as a Record<name, value>
const record = params.asRecord();
// { p_0: 18, p_1: 'active' }
Parametersexposes onlyadd(),asRecord(), andsize. The dialect-specific placeholder text (:p_0:, etc.) is produced by the translator's_parameterizehelper, not byParameters.
import type { Query } from '@tundralibs/oql';
import { PostgresTranslator } from '@tundralibs/oql/translator';
const translator = new PostgresTranslator();
const query: Query<'SELECT'> = {
type: 'SELECT',
table: 'users',
columns: ['id', 'email', 'username', 'age'],
projection: {
'@id': 'userId',
'@email': 'userEmail',
'@username': 'userName',
},
where: {
'@email': { $like: '%@company.com' },
'@age': { $between: [18, 65] },
},
orderBy: { '@username': 'ASC' },
limit: 50,
};
const { sql, params } = translator.select(query);
console.log(sql);
// SELECT "id" AS "userId", "email" AS "userEmail", "username" AS "userName"
// FROM "users"
// WHERE "email" LIKE :p_0: AND "age" BETWEEN :p_1: AND :p_2:
// ORDER BY "username" ASC
// LIMIT 50
console.log(params);
// { p_0: '%@company.com', p_1: 18, p_2: 65 }import type { Query } from '@tundralibs/oql';
import { PostgresTranslator } from '@tundralibs/oql/translator';
const translator = new PostgresTranslator();
const query: Query<'INSERT'> = {
type: 'INSERT',
table: 'orders',
columns: ['userId', 'total', 'createdAt'],
data: {
userId: 123,
total: 99.99,
createdAt: { $$_expression: 'NOW' },
},
};
const { sql, params } = translator.insert(query);
// sql: INSERT INTO "orders" ("userId", "total", "createdAt")
// VALUES (:p_0:, :p_1:, CURRENT_TIMESTAMP) RETURNING "userId", "total", "createdAt"
// params: { p_0: 123, p_1: 99.99 }
// (Postgres NOW emits CURRENT_TIMESTAMP; MariaDB emits NOW())import type { Query } from '@tundralibs/oql';
import { PostgresTranslator } from '@tundralibs/oql/translator';
const translator = new PostgresTranslator();
const query: Query<'SELECT'> = {
type: 'SELECT',
table: 'orders',
columns: ['id', 'userId', 'total'],
joins: {
// A JOIN spec needs its own `table` and `columns` (the columns the
// joined table exposes) in addition to `on`.
'users': {
table: 'users',
columns: ['id', 'email'],
type: 'INNER',
on: { '@users.@id': '@userId' },
},
},
aggregates: {
'totalRevenue': { $$_aggregate: 'SUM', column: '@total' },
'orderCount': { $$_aggregate: 'COUNT', column: '@id' },
},
projection: {
'@userId': 'customerId',
'@users.@email': 'customerEmail',
'@totalRevenue': true,
'@orderCount': true,
},
having: {
'@totalRevenue': { $gte: 1000 },
},
orderBy: { '@totalRevenue': 'DESC' },
};
const { sql, params } = translator.select(query);
// Generated SQL with JOINs, GROUP BY, and HAVINGimport type { Query } from '@tundralibs/oql';
import { PostgresTranslator } from '@tundralibs/oql/translator';
const translator = new PostgresTranslator();
const query: Query<'UPSERT'> = {
type: 'UPSERT',
table: 'users',
columns: ['id', 'email', 'updatedAt'],
data: {
id: 123,
email: 'user@example.com',
updatedAt: { $$_expression: 'NOW' },
},
conflictKeys: ['@id'],
updateOnConflict: ['@email', '@updatedAt'],
};
const { sql, params } = translator.upsert(query);
// PostgreSQL: INSERT ... ON CONFLICT (id) DO UPDATE SET ...
// MariaDB: INSERT ... ON DUPLICATE KEY UPDATE ...
// SQLite: INSERT ... ON CONFLICT (id) DO UPDATE SET ...import type { Query } from '@tundralibs/oql';
import { MongoTranslator } from '@tundralibs/oql/translator';
const translator = new MongoTranslator();
const query: Query<'SELECT'> = {
type: 'SELECT',
table: 'users',
columns: ['id', 'email', 'age'],
projection: { '@id': true, '@email': true, '@age': true },
where: {
$and: [
{ '@age': { $gte: 18 } },
{ '@email': { $endsWith: '@company.com' } },
],
},
orderBy: { '@age': 'DESC' },
limit: 100,
};
const action = translator.select(query);
// {
// sql: 'find',
// params: {
// collection: 'users',
// filter: {
// $and: [
// { age: { $gte: 18 } },
// { email: { $regex: '@company\\.com$' } }
// ]
// },
// options: {
// projection: { id: 1, email: 1, age: 1 },
// sort: { age: -1 },
// limit: 100
// }
// }
// }import type { Query } from '@tundralibs/oql';
import { PostgresTranslator } from '@tundralibs/oql/translator';
const translator = new PostgresTranslator();
const query: Query<'CREATE_TABLE'> = {
type: 'CREATE_TABLE',
table: 'users',
schema: 'public',
// `columns` is a KEYED object (column name → ColumnDefinition); there is
// no `name` field on a column, and ColumnDefinition has no `default`.
columns: {
id: { type: 'INTEGER', nullable: false },
email: { type: 'VARCHAR', length: 255, nullable: false },
createdAt: { type: 'TIMESTAMP' },
},
primaryKey: ['id'],
// `uniqueKeys` is keyed: constraint name → column names.
uniqueKeys: { uq_email: ['email'] },
ifNotExists: true,
};
// createTable returns TranslatedQuery[] (an array — a dialect may emit
// extra index/constraint statements that can't go inline).
const [{ sql }] = translator.createTable(query);
// CREATE TABLE IF NOT EXISTS "public"."users" ("id" INTEGER NOT NULL, "email" VARCHAR(255) NOT NULL, "createdAt" TIMESTAMP, PRIMARY KEY ("id"), CONSTRAINT "uq_email" UNIQUE ("email"))A filter key of the form @col.@key — or deeper, @col.@a.@b — where
col is a declared column of the base table compares against a value
inside that column's JSON document. The translators emit each dialect's
native extraction as the predicate's left-hand side and reuse the standard
operator machinery (parameter binding, LIKE ESCAPE handling) on top of
it:
| Dialect | Single level (@profile.@name) |
Deep (@profile.@a.@b) |
|---|---|---|
| PostgreSQL | "profile"->>'name' |
"profile"#>>'{a,b}' |
| MariaDB | JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$.name')) |
JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$.a.b')) |
| SQLite | json_extract("profile", '$.name') |
json_extract("profile", '$.a.b') |
| MongoDB | native dotted path profile.name
|
profile.a.b |
import type { Query } from '@tundralibs/oql';
import { PostgresTranslator } from '@tundralibs/oql/translator';
const translator = new PostgresTranslator();
const query: Query<'SELECT'> = {
type: 'SELECT',
table: 'users',
columns: ['id', 'profile'],
projection: { '@id': true },
where: { '@profile.@name': { $eq: 'bob' } },
};
const { sql, params } = translator.select(query);
// sql: SELECT "id" AS "id" FROM "users" WHERE "profile"->>'name' = :p_0:
// params: { p_0: 'bob' }The same @a.@b syntax also spells join-alias and base-table
qualification, so a multi-segment key resolves by precedence:
- First segment is a declared join alias → qualified joined column (existing behavior, full operator set).
- First segment is the base table name → table-qualified column (existing behavior).
- First segment is a declared base column → JSON path extraction.
- Anything else → error.
A join alias that shares its name with a column therefore always resolves as the JOIN; the JSON-path interpretation only fires where the key would previously have been rejected, which is what keeps the feature fully backward compatible.
JSON-path keys accept $eq, $ne, $null, $in / $nin (scalar
arrays), the LIKE family ($like, $nlike, $ilike, $nilike), and the
literal-substring operators ($startsWith, $endsWith, $contains) —
plus the direct-value shorthands (null, scalar, array) that desugar to
them.
The ordered comparisons — $gt, $gte, $lt, $lte, $between — are
rejected with a JSON_PATH_UNSUPPORTED_OPERATOR error: extraction
yields text on PostgreSQL/MariaDB but natively typed values on SQLite,
so a numeric range predicate would silently return different rows per
dialect. OQL refuses rather than emitting wrong SQL.
- No numeric ranges — see above; compare for equality/membership or pattern-match only. All comparisons are textual on PostgreSQL/MariaDB.
-
No array indexes — path segments are object keys only (
$.a.b, never$[0]). -
No containment operators — Postgres's
@>/<@/?are not exposed. -
Identifier-safe keys only — every path segment must match the
@-identifier pattern (letters, digits, underscore, not starting with a digit). JSON keys with spaces, dashes, or other punctuation are not expressible in v1. - Join-alias precedence — a join alias shadows a same-named column (rule 1 above); rename the alias if you need a JSON path into that column.
-
Filter positions only —
whereon SELECT / COUNT / UPDATE / DELETE. JSON paths are not part of projections, ORDER BY, GROUP BY, or HAVING.
-
PostgreSQL: Double quotes
"table"."column" -
MariaDB: Backticks
`table`.`column` -
SQLite: Double quotes
"table"."column"
All SQL translators emit the same engine-compat named form on the way out of
OQL; the Postgres engine rewrites it to $N downstream.
-
PostgreSQL: Named
:p_0:, :p_1:, :p_2:(engine rewrites to$1, $2, $3) -
MariaDB: Named
:p_0:, :p_1:, :p_2: -
SQLite: Named
:p_0:, :p_1:, :p_2:
-
PostgreSQL:
ON CONFLICT ... DO UPDATE -
MariaDB:
ON DUPLICATE KEY UPDATE -
SQLite:
ON CONFLICT ... DO UPDATE
What the OQL NOW / CURRENT_DATE expressions emit per dialect:
-
PostgreSQL:
NOW→CURRENT_TIMESTAMP,CURRENT_DATE→CURRENT_DATE -
MariaDB:
NOW→NOW(),CURRENT_DATE→CURRENT_DATE() -
SQLite:
NOW/CURRENT_TIMESTAMP→strftime('%Y-%m-%dT%H:%M:%fZ', 'now')(ISO 8601 UTC, the text SQLite drivers write for aDate, so values parse as UTC and sort correctly),CURRENT_DATE→date('now')
Translators throw DialectUnsupportedError for operations a dialect can't
emit and can't emulate. The message format is
Dialect '<dialect>' does not support <feature> (the dialect name is
lowercase: 'postgres', 'maria', 'sqlite', 'mongo').
For example, MariaDB has no FULL JOIN:
import {
DialectUnsupportedError,
MariaTranslator,
} from '@tundralibs/oql/translator';
const translator = new MariaTranslator();
try {
translator.select({
type: 'SELECT',
table: 'orders',
columns: ['id'],
joins: {
Users: {
table: 'users',
columns: ['id'],
type: 'FULL',
on: { '@Users.@id': '@userId' },
},
},
projection: { '@id': true },
});
} catch (error) {
if (error instanceof DialectUnsupportedError) {
console.log(error.message);
// "Dialect 'maria' does not support FULL JOIN"
}
}Note that many operations are emulated rather than rejected — e.g.
TRUNCATE on SQLite emits DELETE FROM, and a schema on a SQLite
CREATE_TABLE/SELECT/etc. is folded into the identifier as a
<schema>_<name> prefix rather than dot-qualified. CREATE_SCHEMA /
DROP_SCHEMA on SQLite specifically DO throw — there is no schema
object to create or drop. See the Compatibility Matrix for which
features throw versus degrade gracefully.
- Parameter binding - All values are parameterized for security
- Efficient generation - Minimal string allocations
- No runtime overhead - Direct translation without intermediate steps
OQL translators aim for cross-database compatibility, but some features have dialect-specific behavior:
- Graceful Degradation: Features without native support fall back to alternatives (e.g., materialized views → regular views on MariaDB/SQLite)
-
Explicit Errors: Unsupported features that can't be emulated throw
DialectUnsupportedError - Silent Passthroughs: Some operations (HASH, ENCRYPT on SQLite/MongoDB) passthrough unchanged for portability
For comprehensive dialect differences, feature support matrices, and edge cases, see:
📖 Database Compatibility Matrix
This reference covers:
- DDL differences (schemas, materialized views, indexes)
- DML nuances (RETURNING, UPSERT semantics)
- Expression support (UUID, HASH, ENCRYPT/DECRYPT)
- Aggregate function availability
- JOIN type support
- When translators throw vs. gracefully degrade
- Type System - Query type definitions
- Validators - Runtime validation
- Compatibility - Feature compatibility matrix