Skip to content

OQL Translator

GitHub Actions edited this page Sep 27, 2026 · 11 revisions

OQL Translator

SQL and NoSQL query translators for OQL.

Deno Bun Node.js

Table of Contents

Overview

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

Installation

Deno:

deno add @tundralibs/oql

Bun:

bunx jsr add @tundralibs/oql

Node.js:

npx jsr add @tundralibs/oql

Supported Databases

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 keys p_0, p_1, … . The numbered $1, $2, … form Postgres ultimately uses is produced later by the @tundralibs/drivers Postgres engine, outside OQL — the translator itself never emits $N.

Translator API

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:

DML Operations

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.

TranslatedQuery

The SQL translators return this structure (the MongoTranslator returns MongoAction instead — see NoSQL Translators):

type TranslatedQuery = {
  sql: string;
  params: Record<string, unknown>;
};

SQL Translators

PostgresTranslator

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.@key filter 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.

MariaTranslator

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.@key filter keys → JSON_UNQUOTE(JSON_EXTRACT(…)) — MariaDB has no MySQL-style ->>)
  • LIMIT/OFFSET support
  • ON DUPLICATE KEY UPDATE for UPSERT

SQLiteTranslator

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.@key filter keys → json_extract(), SQLite 3.38+)
  • Limited DDL operations
  • No schema object (CREATE_SCHEMA / DROP_SCHEMA throw); schema on every other query is folded into the name as a <schema>_<name> prefix

NoSQL Translators

MongoTranslator

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 UUID itself (see Compatibility)

Join type is not honoured. __buildLookup reads a join's table, columns, and on, but never its type — INNER, LEFT, RIGHT, and FULL all emit the identical $lookup stage (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. RIGHT is not emulated by reversing collections and FULL does not throw — both silently behave like LEFT. See the Compatibility Matrix for the full breakdown before relying on join type on Mongo.

Pipeline-ordering guarantees:

  • A where $match is emitted only after the stages that materialise every field it references — after $lookup when it references a joined field in either key or value position (e.g. { '@createdAt': { $gt: '@Author.@joinedAt' } }), and after the expressions $addFields when 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 against missing and 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.

Parameters

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' }

Parameters exposes only add(), asRecord(), and size. The dialect-specific placeholder text (:p_0:, etc.) is produced by the translator's _parameterize helper, not by Parameters.

Usage Examples

Basic SELECT Translation

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 }

INSERT with Expressions

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())

SELECT with JOINs and Aggregates

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 HAVING

UPSERT Translation

import 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 ...

MongoDB Translation

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
//     }
//   }
// }

CREATE TABLE Translation

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"))

JSON Path Filtering

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' }

Disambiguation

The same @a.@b syntax also spells join-alias and base-table qualification, so a multi-segment key resolves by precedence:

  1. First segment is a declared join alias → qualified joined column (existing behavior, full operator set).
  2. First segment is the base table name → table-qualified column (existing behavior).
  3. First segment is a declared base column → JSON path extraction.
  4. 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.

Supported operators

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.

v1 limitations

  • 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 — where on SELECT / COUNT / UPDATE / DELETE. JSON paths are not part of projections, ORDER BY, GROUP BY, or HAVING.

Dialect Differences

Identifier Quoting

  • PostgreSQL: Double quotes "table"."column"
  • MariaDB: Backticks `table`.`column`
  • SQLite: Double quotes "table"."column"

Parameter Style

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:

UPSERT Syntax

  • PostgreSQL: ON CONFLICT ... DO UPDATE
  • MariaDB: ON DUPLICATE KEY UPDATE
  • SQLite: ON CONFLICT ... DO UPDATE

Date Functions

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 a Date, so values parse as UTC and sort correctly), CURRENT_DATE → date('now')

Error Handling

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.

Performance

  • Parameter binding - All values are parameterized for security
  • Efficient generation - Minimal string allocations
  • No runtime overhead - Direct translation without intermediate steps

Database Compatibility

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

Related Documentation


← Back to OQL

Clone this wiki locally