Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion bin/startora
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ PORT="${TYPEGRES_ORA_PORT:-1521}"
APP_USER="${TYPEGRES_ORA_USER:-typegres}"
APP_PASSWORD="${TYPEGRES_ORA_PASSWORD:-typegres}"
SYS_PASSWORD="${TYPEGRES_ORA_SYS_PASSWORD:-oracle}"
URL="${ORACLE_URL:-${APP_USER}/${APP_PASSWORD}@localhost:${PORT}/FREEPDB1}"
URL_USER="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$APP_USER")"
URL_PASSWORD="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$APP_PASSWORD")"
URL="${ORACLE_URL:-oracle://${URL_USER}:${URL_PASSWORD}@localhost:${PORT}/FREEPDB1}"

if [ -z "${ORACLE_URL:-}" ]; then
export ORACLE_URL="$URL"
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
"import": "./dist/types/sqlite/index.mjs",
"types": "./dist/types/sqlite/index.d.mts"
},
"./oracle": {
"import": "./dist/types/oracle/index.mjs",
"types": "./dist/types/oracle/index.d.mts"
},
"./exoeval": {
"import": "./dist/exoeval/index.mjs",
"types": "./dist/exoeval/index.d.mts"
Expand Down Expand Up @@ -93,6 +97,7 @@
"scripts": {
"build": "tsdown",
"codegen": "node --experimental-strip-types src/types/postgres/emit.ts && node --experimental-strip-types src/types/sqlite/emit.ts",
"codegen:oracle": "node --experimental-strip-types src/types/oracle/emit.ts",
"codegen:check": "tmp=$(mktemp -d) && node --experimental-strip-types src/types/postgres/emit.ts --out-dir \"$tmp/pg\" && { diff -r \"$tmp/pg\" src/types/postgres/generated || { echo 'src/types/postgres/generated is stale - run `npm run codegen` and commit.' >&2; rm -rf \"$tmp\"; exit 1; }; } && node --experimental-strip-types src/types/sqlite/emit.ts --out-dir \"$tmp/sqlite\" && { diff -r \"$tmp/sqlite/generated\" src/types/sqlite/generated || { echo 'src/types/sqlite/generated is stale - run `npm run codegen` and commit.' >&2; rm -rf \"$tmp\"; exit 1; }; } && rm -rf \"$tmp\"",
"lint": "eslint src",
"typecheck": "tsgo --noEmit",
Expand Down
33 changes: 33 additions & 0 deletions src/drivers/oracle-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type oracledb from "oracledb";

export const parseOraclePoolAttributes = (value: string): oracledb.PoolAttributes => {
try {
const url = new URL(value);
const user = decodeURIComponent(url.username);
const password = decodeURIComponent(url.password);
const service = decodeURIComponent(url.pathname);
if (
url.protocol !== "oracle:" ||
!user ||
!password ||
!url.host ||
service === "" ||
service === "/"
) {
throw new Error();
}
return { user, password, connectString: `${url.host}${service}` };
} catch {
throw new Error(
`ORACLE_URL must be oracle://user:password@host:port/service, got ${JSON.stringify(value)}`,
);
}
};

export const requireOraclePoolAttributes = (): oracledb.PoolAttributes => {
const value = process.env["ORACLE_URL"];
if (!value) {
throw new Error("ORACLE_URL is not set. Run bin/startora and export its URL before Oracle codegen.");
}
return parseOraclePoolAttributes(value);
};
11 changes: 3 additions & 8 deletions src/drivers/oracle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,12 @@
// surface, no query builder — those land in later steps.
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { OracleDriver } from "./oracle";
import { parseOraclePoolAttributes } from "./oracle-url";
import { Database } from "../database";
import { compile, sql } from "../builder/sql";
import type { Connection } from "../database";

const url = process.env["ORACLE_URL"];
const parseUrl = (s: string) => {
const m = /^([^/]+)\/([^@]+)@(.+)$/.exec(s);
if (!m) {
throw new Error(`ORACLE_URL must be user/password@host:port/service, got ${JSON.stringify(s)}`);
}
return { user: m[1]!, password: m[2]!, connectString: m[3]! };
};

// Always-on: Connection construction + compile + statement execute, no Oracle process.
test("oracle Connection constructs without a live engine", async () => {
Expand Down Expand Up @@ -47,7 +41,8 @@ describe.skipIf(!url)("oracle driver", () => {
let conn: Connection;

beforeAll(async () => {
driver = await OracleDriver.create(parseUrl(url!));
if (!url) { throw new Error("ORACLE_URL is not set"); }
driver = await OracleDriver.create(parseOraclePoolAttributes(url));
db = new Database();
conn = db.connect(driver);
});
Expand Down
24 changes: 18 additions & 6 deletions src/drivers/oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,19 @@ import type { Driver, ExecuteFn, QueryResult } from "./types";
// imports `typegres/drivers/oracle`.
//
// fetchAsString is the full set node-oracledb accepts (NUMBER/DATE/
// BUFFER/CLOB/NCLOB). VARCHAR2 is already a string. Same contract as
// PgDriver: the driver returns raw text; typed coercion is downstream.
// BUFFER/CLOB/NCLOB). VARCHAR2 is already a string; BLOB is fetched as
// Buffer. Typed coercion remains downstream.

const oracleBinds = (values: readonly unknown[]): oracledb.BindParameters =>
values.map((v) => v instanceof Uint8Array ? Buffer.from(v) : v) as oracledb.BindParameters;

const normalizeRows = (rows: unknown[] | undefined): QueryResult["rows"] =>
(rows ?? []).map((row) => Object.fromEntries(
Object.entries(row as { [key: string]: unknown }).map(([key, value]) => [
key,
Buffer.isBuffer(value) ? value.toString("hex").toUpperCase() : value,
]),
)) as QueryResult["rows"];

let fetchConfigured = false;
const configureFetch = (): void => {
Expand All @@ -24,6 +35,7 @@ const configureFetch = (): void => {
oracledb.CLOB,
oracledb.NCLOB,
];
oracledb.fetchAsBuffer = [oracledb.BLOB];
fetchConfigured = true;
};

Expand All @@ -40,11 +52,11 @@ export class OracleDriver implements Driver {
async execute({ text, values }: CompiledSql): Promise<QueryResult> {
const conn = await this.pool.getConnection();
try {
const result = await conn.execute(text, values as oracledb.BindParameters, {
const result = await conn.execute(text, oracleBinds(values), {
outFormat: oracledb.OUT_FORMAT_OBJECT,
autoCommit: true,
});
return { rows: (result.rows ?? []) as QueryResult["rows"] };
return { rows: normalizeRows(result.rows) };
} finally {
await conn.close();
}
Expand All @@ -54,11 +66,11 @@ export class OracleDriver implements Driver {
const conn = await this.pool.getConnection();
try {
return await cb(async ({ text, values }) => {
const result = await conn.execute(text, values as oracledb.BindParameters, {
const result = await conn.execute(text, oracleBinds(values), {
outFormat: oracledb.OUT_FORMAT_OBJECT,
autoCommit: false,
});
return { rows: (result.rows ?? []) as QueryResult["rows"] };
return { rows: normalizeRows(result.rows) };
});
} finally {
await conn.close();
Expand Down
21 changes: 12 additions & 9 deletions src/types/emission/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export const emitMethods = (host: TypeEntry, allFns: EmitFn[], cfg: EmitConfig):
...params
.map((a, i) => (included(o.args.indexOf(a)) ? `runtime.NullOf<M${i}>` : null))
.filter((x): x is string => x !== null),
...(o.variadic && included(o.args.length - 1) ? ["runtime.NullOf<R[number]>"] : []),
];
const nullUnion = nullParts.join(" | ");
if (o.nullability === "maybe_null") {
Expand All @@ -192,7 +193,7 @@ export const emitMethods = (host: TypeEntry, allFns: EmitFn[], cfg: EmitConfig):
if (o.nullability === "always" || o.nullability === "on_error") {
return formatTypeWithNull(retBase, "0 | 1");
}
if (params.length === 0) {
if (params.length === 0 && !o.variadic) {
return formatTypeWithNull(retBase, nullParts[0] ?? "N");
}
return formatTypeWithNull(retBase, `runtime.StrictNull<${nullUnion}>`);
Expand Down Expand Up @@ -303,31 +304,33 @@ export const emitMethods = (host: TypeEntry, allFns: EmitFn[], cfg: EmitConfig):
// params remain and the method is rest-only.
const fixed = params;
if (receiverGeneric(o)) {
// T binds the receiver (the return refers to it), so args don't
// need their own generics — plain unions suffice.
// T binds the receiver (the return refers to it), so fixed args
// don't need their own generics — plain unions suffice.
const paramSrc = fixed.map((a, i) => {
const resolved = formatTypeWithNull(resolveType(a.type, host, table), "any");
const prim = allowPrimitive ? primitiveUnionFor(a.type) : null;
return `arg${i}${a.optional ? "?" : ""}: ${resolved}${prim ? ` | ${prim}` : ""}`;
});
const generics = ["T extends types.Any<any>"];
if (variadic) {
const last = o.args[o.args.length - 1]!;
const resolved = formatTypeWithNull(resolveType(last.type, host, table), "any");
const prim = primitiveUnionFor(last.type);
paramSrc.push(`...rest: (${resolved}${prim ? ` | ${prim}` : ""})[]`);
generics.push(`R extends (${resolved}${prim ? ` | ${prim}` : ""})[]`);
paramSrc.push("...rest: R");
}
return `${name}<T extends types.Any<any>>(${["this: T", ...paramSrc].join(", ")})`;
return `${name}<${generics.join(", ")}>(${["this: T", ...paramSrc].join(", ")})`;
}
const genericDecl = fixed.length > 0
? `<${fixed.map((a, i) => buildArgGeneric(a, i, allowPrimitive)).join(", ")}>`
: "";
const generics = fixed.map((a, i) => buildArgGeneric(a, i, allowPrimitive));
const paramSrc = fixed.map((a, i) => `arg${i}${a.optional ? "?" : ""}: M${i}`);
if (variadic) {
const last = o.args[o.args.length - 1]!;
const resolved = formatTypeWithNull(resolveType(last.type, host, table), "any");
const prim = primitiveUnionFor(last.type);
paramSrc.push(`...rest: (${resolved}${prim ? ` | ${prim}` : ""})[]`);
generics.push(`R extends (${resolved}${prim ? ` | ${prim}` : ""})[]`);
paramSrc.push("...rest: R");
}
const genericDecl = generics.length > 0 ? `<${generics.join(", ")}>` : "";
return `${name}${genericDecl}(${paramSrc.join(", ")})`;
};

Expand Down
117 changes: 117 additions & 0 deletions src/types/oracle/common.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, expectTypeOf, test } from "vitest";
import { compile, sql, type Sql } from "../../builder/sql";
import { compileOnlyDb } from "../../test-helpers";
import { Bool, Date as OraDate, Number as OraNumber, Varchar2 } from "./index";

const db = compileOnlyDb("oracle");
const compileOracle = (value: { toSql(): Sql }) =>
compile(value.toSql(), { database: db });

describe("common Oracle supplement", () => {
test("numeric operators", () => {
expect(compileOracle(OraNumber.from(5).plus(3).times(2).negate())).toEqual({
text: "(- ((CAST(:1 AS NUMBER) + CAST(:2 AS NUMBER)) * CAST(:3 AS NUMBER)))",
values: [5, 3, 2],
});
});

test("date arithmetic", () => {
expect(compileOracle(OraDate.from(sql`DATE '2025-01-15'`).plus(2))).toEqual({
text: "(DATE '2025-01-15' + CAST(:1 AS NUMBER))",
values: [2],
});
expect(compileOracle(
OraDate.from(sql`DATE '2025-01-15'`).minus(OraDate.from(sql`DATE '2025-01-10'`)),
)).toEqual({
text: "(DATE '2025-01-15' - DATE '2025-01-10')",
values: [],
});
});

test("word operators derive camelCase aliases", () => {
expect(compileOracle(Varchar2.from("hello").like("h%"))).toEqual({
text: "(CAST(:1 AS VARCHAR2(4000)) LIKE CAST(:2 AS VARCHAR2(4000)))",
values: ["hello", "h%"],
});
expect(compileOracle(Varchar2.from("hello").notLike("x%"))).toEqual({
text: "(CAST(:1 AS VARCHAR2(4000)) NOT LIKE CAST(:2 AS VARCHAR2(4000)))",
values: ["hello", "x%"],
});
});

test("NVL2 dispatches its result arguments", () => {
expect(compileOracle(OraNumber.from(1).nvl2("yes", "no"))).toEqual({
text: '"NVL2"(CAST(:1 AS NUMBER), CAST(:2 AS VARCHAR2(4000)), CAST(:3 AS VARCHAR2(4000)))',
values: [1, "yes", "no"],
});
});

test("common scalar additions", () => {
expect(compileOracle(OraNumber.from(5).widthBucket(0, 10, 5))).toEqual({
text: '"WIDTH_BUCKET"(CAST(:1 AS NUMBER), CAST(:2 AS NUMBER), CAST(:3 AS NUMBER), CAST(:4 AS NUMBER))',
values: [5, 0, 10, 5],
});
expect(compileOracle(Bool.from(false).lnnvl())).toEqual({
text: '"LNNVL"(CAST(:1 AS BOOLEAN))',
values: [false],
});
});

test("GREATEST and LEAST accept variadic values", () => {
expectTypeOf(OraNumber.from(5).greatest(7, 3)).toEqualTypeOf<OraNumber<1>>();
expectTypeOf(OraNumber.from(5).greatest(OraNumber.from(sql`NULL`)))
.toEqualTypeOf<OraNumber<0 | 1>>();
expect(compileOracle(OraNumber.from(5).greatest(7, 3))).toEqual({
text: '"GREATEST"(CAST(:1 AS NUMBER), CAST(:2 AS NUMBER), CAST(:3 AS NUMBER))',
values: [5, 7, 3],
});
expect(compileOracle(Varchar2.from("b").least("a", "c"))).toEqual({
text: '"LEAST"(CAST(:1 AS VARCHAR2(4000)), CAST(:2 AS VARCHAR2(4000)), CAST(:3 AS VARCHAR2(4000)))',
values: ["b", "a", "c"],
});
});

test("LISTAGG always returns nullable VARCHAR2", () => {
const value = OraNumber.from(5).listagg(",");
expectTypeOf(value).toEqualTypeOf<Varchar2<0 | 1>>();
expect(compileOracle(value)).toEqual({
text: '"LISTAGG"(CAST(:1 AS NUMBER), CAST(:2 AS VARCHAR2(4000)))',
values: [5, ","],
});
});

test("NULLIF is available on common types and always nullable", () => {
const value = OraNumber.from(5).nullif(5);
expectTypeOf(value).toEqualTypeOf<OraNumber<0 | 1>>();
expect(compileOracle(value)).toEqual({
text: '"NULLIF"(CAST(:1 AS NUMBER), CAST(:2 AS NUMBER))',
values: [5, 5],
});
});

test("format arguments are optional where Oracle permits omission", () => {
expect(compileOracle(OraNumber.from(5).toChar())).toEqual({
text: '"TO_CHAR"(CAST(:1 AS NUMBER))',
values: [5],
});
expect(compileOracle(Varchar2.from("1.5").toBinaryDouble())).toEqual({
text: '"TO_BINARY_DOUBLE"(CAST(:1 AS VARCHAR2(4000)))',
values: ["1.5"],
});
});

test("numeric statistical aggregates stay on NUMBER", () => {
const value = OraNumber.from(1).corr(2);
expectTypeOf(value).toEqualTypeOf<OraNumber<0 | 1>>();
expect(compileOracle(value)).toEqual({
text: '"CORR"(CAST(:1 AS NUMBER), CAST(:2 AS NUMBER))',
values: [1, 2],
});
expectTypeOf<"corr" extends keyof Varchar2<1> ? true : false>().toEqualTypeOf<false>();
});

test("nullable functions and excluded partial forms have honest types", () => {
expectTypeOf(Varchar2.from("abc").regexpSubstr("z")).toEqualTypeOf<Varchar2<0 | 1>>();
expectTypeOf<"decode" extends keyof Varchar2<1> ? true : false>().toEqualTypeOf<false>();
});
});
21 changes: 21 additions & 0 deletions src/types/oracle/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { parseOraclePoolAttributes } from "../../drivers/oracle-url";

describe("Oracle connection configuration", () => {
test("parses standard URLs and percent-encoded credentials", () => {
expect(parseOraclePoolAttributes(
"oracle://type%2Fgres:p%40ss%2Fword@localhost:1521/FREEPDB1",
)).toEqual({
user: "type/gres",
password: "p@ss/word",
connectString: "localhost:1521/FREEPDB1",
});
});

test("rejects incomplete URLs without connecting", () => {
expect(() => parseOraclePoolAttributes("oracle://user:pass@localhost:1521"))
.toThrow("ORACLE_URL must be");
expect(() => parseOraclePoolAttributes("typegres/typegres@localhost:1521/FREEPDB1"))
.toThrow("ORACLE_URL must be");
});
});
Loading
Loading