/** * Tina4 SQL Translation — Cross-engine SQL translator. * * Translates SQL dialect differences between engines so that application * code can use a single SQL style and have it adapted at runtime. * * import { SQLTranslator } from "@tina4/orm"; * * // Firebird: LIMIT/OFFSET → ROWS X TO Y * SQLTranslator.limitToRows("SELECT * FROM users LIMIT 10 OFFSET 5"); * // → "SELECT * FROM users ROWS 6 TO 15" * * // MSSQL: LIMIT → TOP N * SQLTranslator.limitToTop("SELECT * FROM users LIMIT 10"); * // → "SELECT TOP 10 * FROM users" * * Also includes a query cache with TTL support. */ export declare class SQLTranslator { private static readonly SPATIAL_ENGINES; private static readonly SPATIAL_IDENTIFIER; static requireSpatial(engine: string, feature: string): string; static spatialIdentifier(name: string, what?: string): string; static pointColumnType(engine: string, srid?: number): string; static spatialIndex(engine: string, table: string, column: string): string; static pointLiteral(engine: string, srid?: number): string; static withinDistance(engine: string, column: string, srid?: number): string; static distance(engine: string, column: string, srid?: number): string; static distanceAs(engine: string, column: string, alias: string, srid?: number): string; static geometryLiteral(engine: string, form: "ewkt" | "geojson", srid?: number): string; static intersects(engine: string, column: string, form?: "ewkt" | "geojson", srid?: number): string; static bbox(engine: string, column: string, srid?: number): string; /** * Convert LIMIT/OFFSET to Firebird ROWS...TO syntax. * * LIMIT 10 OFFSET 5 → ROWS 6 TO 15 * LIMIT 10 → ROWS 1 TO 10 */ static limitToRows(sql: string): string; /** * Convert LIMIT to MSSQL TOP syntax. * * SELECT ... LIMIT 10 → SELECT TOP 10 ... * Does NOT convert if OFFSET is present (TOP doesn't support it). */ static limitToTop(sql: string): string; /** Replace string literals, quoted identifiers and comments with opaque * `\x00N\x00` tokens (doubled-quote escapes handled). */ private static maskLiterals; /** Inverse of maskLiterals. */ private static restoreLiterals; private static readonly PRIMARY; /** * Convert `||` string concatenation to `CONCAT(...)` for MySQL/MSSQL. * * Rewrites ONLY `||` operators joining expression operands OUTSIDE any string * literal or comment, and only the operand chain — never the whole statement: * SELECT a || b FROM t -> SELECT CONCAT(a, b) FROM t * WHERE data = 'a||b' -> WHERE data = 'a||b' (literal untouched) */ static concatPipesToFunc(sql: string): string; /** * Convert bare TRUE/FALSE to 1/0 for engines without a boolean type. A * TRUE/FALSE INSIDE a string literal is data and is left untouched * (`WHERE label = 'TRUE'` is preserved). */ static booleanToInt(sql: string): string; /** * Convert `col ILIKE pattern` to `LOWER(col) LIKE LOWER(pattern)` for engines * without ILIKE. The pattern operand is captured whole (a multi-word * `'%two words%'` survives) and an ILIKE INSIDE a string literal is untouched. */ static ilikeToLike(sql: string): string; /** * Translate AUTOINCREMENT across engines in DDL. */ static autoIncrementSyntax(sql: string, engine: string): string; /** * Translate SQLite-canonical DDL column TYPES + CREATE-TABLE options to the * target engine. * * ONLY acts on `CREATE TABLE` / `ALTER TABLE` statements, so a query or INSERT * that happens to contain the word `TEXT` (a column name, a string literal) is * never rewritten. Complements `autoIncrementSyntax` (which maps the id * keyword) so ONE portable migration — and every `Model.createTable()` DDL, * which is also SQLite-canonical — applies on every engine instead of failing * on Firebird/MSSQL. * * * Firebird has no `TEXT` (-607), no `REAL`, and no `CREATE TABLE IF NOT * EXISTS`. * * MSSQL has no `CREATE TABLE IF NOT EXISTS` and its `TIMESTAMP` is a * rowversion, not a datetime — a `created_at TIMESTAMP` there is wrong. * * MySQL's `TIMESTAMP` carries auto-update / 2038 surprises, so a datetime * column maps to `DATETIME` (matching the adapters' createTableAsync). */ static ddlTypes(sql: string, engine: string): string; /** * Convert ? placeholders to engine-specific style. * * ? → %s (MySQL, PostgreSQL) * ? → :1, :2, :3 (Oracle, Firebird) */ static placeholderStyle(sql: string, style: string): string; /** * Detect and strip RETURNING clause from INSERT/UPDATE statements. * Returns the cleaned SQL and the list of RETURNING columns. * * "INSERT INTO t (x) VALUES (1) RETURNING id, name" * → { sql: "INSERT INTO t (x) VALUES (1)", columns: ["id", "name"] } */ static parseReturning(sql: string): { sql: string; columns: string[]; }; /** * v3.13.14 (#48): split a possibly-qualified table name into [schema, table]. * * A model whose table name is qualified — PostgreSQL "gift_cards.gift_card", * MSSQL "dbo.widget", MySQL "otherdb.table", SQLite "attached.table" — lives * in that schema/catalog, not the default. Adapters use this so tableExists / * getColumns query the right namespace instead of matching the whole dotted * string as one flat name. Returns [null, name] for a bare name. Splits on the * first dot. Firebird has no schemas, so its adapter ignores this. */ static splitSchema(name: string): [string | null, string]; /** * Hard per-statement bind-parameter ceiling per engine. 0 = never collapse. * Sourced from test/fixtures/batch_write_contract.json, byte-identical in all * four frameworks. */ static readonly MAX_BIND_PARAMS: Record; /** * The four frameworks do not agree on what an engine calls itself — Python * and PHP report "postgresql", Ruby and Node report "postgres". Without * normalising, the cap lookup misses and the collapse silently does nothing * on the engine with the largest win. */ static readonly ENGINE_ALIASES: Record; private static readonly INSERT_VALUES; /** * Engines whose lastInsertId reports the FIRST generated id of a multi-row * INSERT rather than the last. Verified live, not assumed: a 3-row insert * into a fresh MySQL table reports 1 while MAX(id) is 3. SQLite, PostgreSQL * and MSSQL already report the last, so collapsing does not change them. */ static readonly FIRST_ID_ENGINES: readonly string[]; /** * Normalise a collapsed batch's last id to the LAST row's id. * * A row-at-a-time batch reports the last row's id simply because the last * statement inserted the last row. Collapsing rows into one statement changes * that on any engine that reports the FIRST generated id, so this restores * the contract instead of quietly redefining it. The ids in one statement are * consecutive, so the last is `first + rows - 1`. */ static batchLastId(reportedId: unknown, rowsInChunk: number, engine: string): unknown; /** * Collapse a row-at-a-time INSERT batch into chunked multi-row VALUES. * * A batch that loops one INSERT per row pays a full network round-trip per * row, and the round-trip — not SQL building — is the entire cost of a batch * write. Measured over 500 rows: PostgreSQL 9848ms row-at-a-time against * 15.8ms as a single multi-row statement (625x), MySQL 216x, MSSQL 121x. * * PURE: no I/O and no engine contact, so the chunking rules are checkable * without a database. The live-engine runners prove the rows land. * * @returns Statements to run INSTEAD of the loop, or an EMPTY array meaning * "not collapsible — keep looping", which is always correct. */ static buildBatchInserts(sql: string, paramSets: unknown[][], engine: string): Array<[string, unknown[]]>; /** * Blank out string literals, quoted identifiers and comments, so a keyword * search sees only real SQL. Blanks are spaces of the SAME LENGTH (newlines * preserved), so offsets and line structure still line up with the original. * * This exists because "does the caller's SQL already have a LIMIT?" used to be * `sql.toUpperCase().split("--")[0].includes("LIMIT")`, and MEASURED on a real * 150-row table with the 100-row cap in force, every one of these returned * ALL 150 ROWS instead of 100: * * SELECT * FROM t WHERE label != 'LIMIT' ORDER BY id -- literal * SELECT * FROM t ORDER BY id -- LIMIT 5 -- line comment * SELECT * FROM t ORDER BY id /* LIMIT 5 *\/ -- block comment * * A column named `rate_limit` does it too. That is a silently UNCAPPED read of * a whole table, which is the exact production incident the row cap exists to * prevent, reachable through an ordinary column name. * * @param sql Raw SQL, exactly as the caller wrote it. * @returns The same string with literals and comments replaced by spaces. */ static scrubSqlText(sql: string): string; /** * True when the statement ENDS with its own LIMIT clause, so appending another * would be wrong (and on SQLite, a syntax error). * * Anchored to the END on purpose. A bare "contains LIMIT" test also matches a * LIMIT inside a subquery, where the OUTER statement still needs its cap. This * is tina4-php's `SqlNormalizerTrait::hasTrailingLimit` regex, ported verbatim * so all four frameworks answer identically: it accepts a numeric value, `?`, * `$1` and `:name` placeholders, MySQL's `LIMIT a, b`, and a trailing OFFSET. * * @param sql Raw SQL; literals and comments are scrubbed before matching. */ static hasTrailingLimit(sql: string): boolean; /** * Append `LIMIT`/`OFFSET` to a statement unless it already carries its own. * * The clause goes on a NEW LINE. Appending it inline is the second half of the * same bug: `SELECT * FROM t -- note` + ` LIMIT 100` puts the clause INSIDE the * trailing comment, where SQLite silently ignores it and the whole table comes * back. A newline cannot be commented out by a `--` that started on the line * above. Trailing semicolons are stripped first for the same reason * (`SELECT * FROM t;` + `LIMIT 100` is a syntax error). * * @param sql The caller's statement. * @param limit Row cap to apply; a non-positive value means "no cap". * @param offset Rows to skip; omitted or 0 emits no OFFSET. */ static appendLimit(sql: string, limit?: number, offset?: number): string; } /** * Simple in-memory query cache with TTL support. */ export declare class QueryCache { private store; private defaultTtl; private maxSize; constructor(options?: { defaultTtl?: number; maxSize?: number; }); /** * Stable identity of the DATABASE a cache entry came from. * * `engine://host:port/database` - and deliberately NOTHING else. * * WHY IT EXISTS: the key used to be `query:${sql}:${params}` with nothing * naming the connection, so on any SHARED backend two databases cross-served * each other's rows. Two apps pointed at one Redis, or one app with a primary * and an analytics connection, silently read each other's data. Identical SQL * text across tenants is the COMMON case, not an edge case, so the collision * was the normal outcome. * * WHY NO CREDENTIALS: a password in the key means every rotation silently * cold-starts the cache, and a shared backend's key namespace is visible to * every tenant of that backend - a secret must never be folded into it. The * username is out for the same reason plus a second: two connections * differing only by role read the SAME rows and should share the entry. * * WHY NOTHING PER-PROCESS: no pid, no object id, no salt. Those would isolate * the databases by ACCIDENT and destroy the point of a shared cache, because * no instance would ever hit another instance's entry. */ static cacheIdentity(url: string): string; /** * Generate a cache key from DATABASE IDENTITY + SQL + params. * * The NUL separators keep the three parts from running together, so a table * named after the tail of a database name cannot forge another database's * key. The key is not hashed here: the only backend with a key-length limit * is memcached, and its backend already SHA-256-hashes whatever it is given. */ static queryKey(sql: string, params?: unknown[], identity?: string): string; /** * Get a cached value. Returns undefined if expired or missing. */ get(key: string): T | undefined; /** * Set a cached value with optional TTL (seconds) and tags for grouped * invalidation via clearTag(). */ set(key: string, value: T, ttl?: number, tags?: string[]): void; /** * Remove all entries that carry the given tag. Returns the number removed. */ clearTag(tag: string): number; /** * Check if a key exists and is not expired. */ has(key: string): boolean; /** * Delete a specific key. */ delete(key: string): boolean; /** * Remove all expired entries. */ sweep(): number; /** * Clear all cached entries. */ clear(): void; /** * Get the number of cached entries. */ size(): number; /** * Get or set a value using a factory function. */ remember(key: string, ttl: number, factory: () => T): T; }