diff --git a/docs/1.guide/2.capabilities.md b/docs/1.guide/2.capabilities.md new file mode 100644 index 00000000..d6d96634 --- /dev/null +++ b/docs/1.guide/2.capabilities.md @@ -0,0 +1,112 @@ +--- +icon: ph:check-circle-duotone +--- + +# Database Capabilities + +> The `db.capabilities` property exposes database feature support at runtime. + +Use capabilities to write portable code that adapts to the underlying database. + +## Usage + +You can access capabilities directly on the database instance: + +```ts +import { createDatabase } from "db0"; +import sqlite from "db0/connectors/better-sqlite3"; + +const db = createDatabase(sqlite({})); + +console.log(db.capabilities); +// { supportsJSON: true, supportsBooleans: false, supportsArrays: false, ... } + +if (db.capabilities.supportsArrays) { + // Use PostgreSQL array syntax +} else { + // Use JSON or comma-separated values +} +``` + +## Available Flags + +| Flag | Description | +| ----------------------- | ------------------------------------------------------------- | +| `supportsJSON` | The database supports native JSON column types and functions. | +| `supportsBooleans` | The database supports native boolean types (not 0/1). | +| `supportsArrays` | The database supports native array column types. | +| `supportsDates` | The database supports native date/timestamp types. | +| `supportsUUIDs` | The database supports native UUID column types. | +| `supportsTransactions` | The database supports transactions. | +| `supportsBatch` | The database supports batch execution of statements. | + +## Capabilities by Connector + +> [!TIP] +> See [db-compat.onmax.me](https://db-compat.onmax.me) for a comprehensive database feature comparison. + + + + +| Connector | JSON | Bool | Array | Date | UUID | Tx | Batch | +| :--------:|:---:|:---:|:----:|:---:|:---:|:-:|:----: | +| better-sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | +| sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | +| bun-sqlite | ✓ | — | — | — | — | ✓ | ✓ | +| node-sqlite | ✓ | — | — | — | — | ✓ | ✓ | +| libsql | ✓ | — | — | — | — | ✓ | ✓ | +| cloudflare-d1 | ✓ | — | — | — | — | ✓ | ✓ | +| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | +| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | + + + +> [!NOTE] +> Cloudflare Hyperdrive connectors inherit the capabilities of their underlying database (PostgreSQL or MySQL). + +## Use Cases + +### Conditional Feature Usage + +You can adapt your queries based on database support: + +```ts +function storeList(db: Database, items: string[]) { + if (db.capabilities.supportsArrays) { + return db.sql`INSERT INTO data (items) VALUES (${items})`; + } else { + return db.sql`INSERT INTO data (items) VALUES (${JSON.stringify(items)})`; + } +} +``` + +### Runtime Validation + +You can validate that the database meets your application's requirements: + +```ts +function initDatabase(db: Database) { + if (!db.capabilities.supportsTransactions) { + throw new Error("This application requires transaction support"); + } +} +``` + +### Feature Detection in Libraries + +You can build database-agnostic libraries that adapt automatically: + +```ts +export function createRepository(db: Database) { + return { + saveTags(id: string, tags: string[]) { + const value = db.capabilities.supportsArrays + ? tags + : tags.join(","); + return db.sql`UPDATE items SET tags = ${value} WHERE id = ${id}`; + }, + }; +} +``` diff --git a/docs/1.guide/_capabilities-table.md b/docs/1.guide/_capabilities-table.md new file mode 100644 index 00000000..195eaeb3 --- /dev/null +++ b/docs/1.guide/_capabilities-table.md @@ -0,0 +1,15 @@ + +| Connector | JSON | Bool | Array | Date | UUID | Tx | Batch | +| :--------:|:---:|:---:|:----:|:---:|:---:|:-:|:----: | +| better-sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | +| sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | +| bun-sqlite | ✓ | — | — | — | — | ✓ | ✓ | +| node-sqlite | ✓ | — | — | — | — | ✓ | ✓ | +| libsql | ✓ | — | — | — | — | ✓ | ✓ | +| cloudflare-d1 | ✓ | — | — | — | — | ✓ | ✓ | +| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | +| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | +| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | diff --git a/package.json b/package.json index 9c3cb54e..769bbeb3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,10 @@ "./connectors/libsql/*": { "types": "./dist/connectors/libsql/*.d.ts", "default": "./dist/connectors/libsql/*.mjs" + }, + "./capabilities": { + "types": "./dist/capabilities.d.ts", + "default": "./dist/capabilities.mjs" } }, "types": "./dist/index.d.mts", @@ -29,8 +33,9 @@ "dist" ], "scripts": { - "build": "pnpm gen-connectors && obuild", + "build": "pnpm gen-connectors && pnpm gen-capabilities && obuild", "gen-connectors": "jiti scripts/gen-connectors.ts", + "gen-capabilities": "jiti scripts/gen-capabilities-docs.ts", "db0": "pnpm jiti src/cli", "dev": "vitest", "lint": "eslint . && prettier -c src test", diff --git a/scripts/gen-capabilities-docs.ts b/scripts/gen-capabilities-docs.ts new file mode 100644 index 00000000..dd82aaa7 --- /dev/null +++ b/scripts/gen-capabilities-docs.ts @@ -0,0 +1,57 @@ +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { connectorCapabilities } from "../src/connectors/_internal/capabilities.ts"; +import type { DatabaseCapabilities } from "../src/types.ts"; + +const outputFile = fileURLToPath(new URL("../docs/1.guide/_capabilities-table.md", import.meta.url)); + +const db0Connectors = [ + "better-sqlite3", "sqlite3", "bun-sqlite", "node-sqlite", "libsql", "cloudflare-d1", + "postgresql", "pglite", "cloudflare-hyperdrive-postgresql", + "mysql2", "planetscale", "cloudflare-hyperdrive-mysql", +]; + +const capabilityLabels: Record = { + supportsJSON: "JSON", + supportsBooleans: "Bool", + supportsArrays: "Array", + supportsDates: "Date", + supportsUUIDs: "UUID", + supportsTransactions: "Tx", + supportsBatch: "Batch", +}; + +const check = "✓"; +const cross = "—"; + +function generateTable(connectorCapabilities: Record): string { + const headers = ["Connector", ...Object.values(capabilityLabels)]; + const separator = headers.map((h) => ":".padEnd(h.length, "-") + ":"); + + const rows = Object.entries(connectorCapabilities).map(([name, caps]) => { + const cells = Object.keys(capabilityLabels).map((key) => + caps[key as keyof DatabaseCapabilities] ? check : cross, + ); + return [name, ...cells]; + }); + + const lines = [headers.join(" | "), separator.join("|"), ...rows.map((row) => row.join(" | "))]; + return lines.map((line) => `| ${line} |`).join("\n"); +} + +const orderedCapabilities: Record = Object.fromEntries( + db0Connectors.map((id) => { + const caps = connectorCapabilities[id]; + if (!caps) { + throw new Error(`No capabilities mapping found for connector: ${id}`); + } + return [id, caps] as const; + }), +); + +const content = ` +${generateTable(orderedCapabilities)} +`; + +await writeFile(outputFile, content, "utf8"); +console.log("Generated capabilities table to", outputFile); diff --git a/src/capabilities.ts b/src/capabilities.ts new file mode 100644 index 00000000..0a446dae --- /dev/null +++ b/src/capabilities.ts @@ -0,0 +1,49 @@ +import type { DatabaseCapabilities, SQLDialect } from "./types.ts"; + +const sqlite: DatabaseCapabilities = { + supportsJSON: true, + supportsBooleans: false, + supportsArrays: false, + supportsDates: false, + supportsUUIDs: false, + supportsTransactions: true, + supportsBatch: true, +}; + +const postgresql: DatabaseCapabilities = { + supportsJSON: true, + supportsBooleans: true, + supportsArrays: true, + supportsDates: true, + supportsUUIDs: true, + supportsTransactions: true, + supportsBatch: true, +}; + +const mysql: DatabaseCapabilities = { + supportsJSON: true, + supportsBooleans: true, + supportsArrays: false, + supportsDates: true, + supportsUUIDs: false, + supportsTransactions: true, + supportsBatch: true, +}; + +export const dialectCapabilities: Record = { + sqlite, + libsql: sqlite, + postgresql, + mysql, +}; + +export function getCapabilities( + dialect: SQLDialect, + overrides?: Partial, +): DatabaseCapabilities { + return overrides + ? { ...dialectCapabilities[dialect], ...overrides } + : dialectCapabilities[dialect]; +} + +export type { DatabaseCapabilities } from "./types.ts"; diff --git a/src/connectors/_internal/capabilities.ts b/src/connectors/_internal/capabilities.ts new file mode 100644 index 00000000..92e0c1d3 --- /dev/null +++ b/src/connectors/_internal/capabilities.ts @@ -0,0 +1,18 @@ +import { dialectCapabilities } from "../../capabilities.ts"; +import type { DatabaseCapabilities } from "../../types.ts"; + +// Connector-to-capabilities mapping for documentation generation +export const connectorCapabilities: Record = { + "better-sqlite3": dialectCapabilities.sqlite, + sqlite3: dialectCapabilities.sqlite, + "bun-sqlite": dialectCapabilities.sqlite, + "node-sqlite": dialectCapabilities.sqlite, + libsql: dialectCapabilities.libsql, + "cloudflare-d1": dialectCapabilities.sqlite, + postgresql: dialectCapabilities.postgresql, + pglite: dialectCapabilities.postgresql, + "cloudflare-hyperdrive-postgresql": dialectCapabilities.postgresql, + mysql2: dialectCapabilities.mysql, + planetscale: dialectCapabilities.mysql, + "cloudflare-hyperdrive-mysql": dialectCapabilities.mysql, +}; diff --git a/src/database.ts b/src/database.ts index f94b69f9..b33abdab 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,3 +1,4 @@ +import { getCapabilities } from "./capabilities.ts"; import { sqlTemplate } from "./template.ts"; import type { Connector, Database, SQLDialect } from "./types.ts"; import type { Primitive } from "./types.ts"; @@ -34,6 +35,10 @@ export function createDatabase( return connector.dialect; }, + get capabilities() { + return getCapabilities(connector.dialect, connector.capabilityOverrides); + }, + get disposed() { return _disposed; }, diff --git a/src/index.ts b/src/index.ts index 2ce54c19..7fd4bb1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ export { createDatabase } from "./database.ts"; +export { dialectCapabilities, getCapabilities } from "./capabilities.ts"; export { connectors } from "./_connectors.ts"; export type { Connector, Database, + DatabaseCapabilities, ExecResult, Primitive, SQLDialect, diff --git a/src/integrations/drizzle/_utils.ts b/src/integrations/drizzle/_utils.ts index 33141d3b..762f01fd 100644 --- a/src/integrations/drizzle/_utils.ts +++ b/src/integrations/drizzle/_utils.ts @@ -28,7 +28,10 @@ export function mapResultRow( } else if (is(field, SQL)) { decoder = "decoder" in field && (field.decoder as any); } else { - decoder = "decoder" in field.sql && (field.sql.decoder as any); + decoder = + "sql" in field && + "decoder" in field.sql && + (field.sql.decoder as any); } let node = result; for (const [pathChunkIndex, pathChunk] of path.entries()) { diff --git a/src/types.ts b/src/types.ts index 4897e0f0..3a2601e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,16 @@ export type Primitive = string | number | boolean | undefined | null; export type SQLDialect = "mysql" | "postgresql" | "sqlite" | "libsql"; +export interface DatabaseCapabilities { + readonly supportsJSON: boolean; + readonly supportsBooleans: boolean; + readonly supportsArrays: boolean; + readonly supportsDates: boolean; + readonly supportsUUIDs: boolean; + readonly supportsTransactions: boolean; + readonly supportsBatch: boolean; +} + export type Statement = { /** * Binds parameters to the statement. @@ -81,6 +91,11 @@ export type Connector = { */ dialect: SQLDialect; + /** + * Override specific database capabilities for this connector. + */ + capabilityOverrides?: Partial; + /** * The client instance used internally. */ @@ -123,6 +138,11 @@ export interface Database< > extends AsyncDisposable { readonly dialect: SQLDialect; + /** + * Database capabilities supported by this connector. + */ + readonly capabilities: DatabaseCapabilities; + /** * Indicates whether the database instance has been disposed/closed. * @returns {boolean} True if the database has been disposed, false otherwise. diff --git a/test/connectors/_tests.ts b/test/connectors/_tests.ts index 7f42d784..f87e5a2c 100644 --- a/test/connectors/_tests.ts +++ b/test/connectors/_tests.ts @@ -2,6 +2,7 @@ import { beforeAll, expect, it } from "vitest"; import { Connector, Database, + DatabaseCapabilities, createDatabase, type SQLDialect, } from "../../src"; @@ -9,6 +10,7 @@ import { export function testConnector(opts: { connector: TConnector; dialect: SQLDialect; + capabilities?: Partial; }) { let db: Database; beforeAll(() => { @@ -37,6 +39,22 @@ export function testConnector(opts: { expect(db.dialect).toBe(opts.dialect); }); + it("capabilities match", () => { + expect(db.capabilities).toBeDefined(); + expect(typeof db.capabilities.supportsJSON).toBe("boolean"); + expect(typeof db.capabilities.supportsBooleans).toBe("boolean"); + expect(typeof db.capabilities.supportsArrays).toBe("boolean"); + expect(typeof db.capabilities.supportsDates).toBe("boolean"); + expect(typeof db.capabilities.supportsUUIDs).toBe("boolean"); + expect(typeof db.capabilities.supportsTransactions).toBe("boolean"); + expect(typeof db.capabilities.supportsBatch).toBe("boolean"); + if (opts.capabilities) { + for (const [key, value] of Object.entries(opts.capabilities)) { + expect(db.capabilities[key as keyof DatabaseCapabilities]).toBe(value); + } + } + }); + it("drop and create table", async () => { await db.sql`DROP TABLE IF EXISTS users`; switch (opts.dialect) { diff --git a/tsconfig.json b/tsconfig.json index 880cb46b..61277639 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,9 +17,8 @@ "noImplicitOverride": true, "noEmit": true, "paths": { - "db0/connectors/*": [ - "./src/connectors/*" - ] + "db0": ["./src/index.ts"], + "db0/connectors/*": ["./src/connectors/*"] } }, "include": [