Initial commit
This commit is contained in:
commit
2864812fa3
15 changed files with 1370 additions and 0 deletions
2
index.ts
Normal file
2
index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./src/index.ts";
|
||||||
|
export { DatabaseSessionStore } from "./src/sessions.ts";
|
||||||
12
package.json
Normal file
12
package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "@muddle/database",
|
||||||
|
"type": "module",
|
||||||
|
"exports": "./index.ts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"muddle": "*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "^8.23.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
213
src/adapters/d1.ts
Normal file
213
src/adapters/d1.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import type { FieldType } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import { buildCreateTable, type SqlDialect } from "./driver.ts";
|
||||||
|
|
||||||
|
export interface D1Options {
|
||||||
|
/** Account the database belongs to. */
|
||||||
|
accountId: string;
|
||||||
|
/** Identifier of the database. */
|
||||||
|
databaseId: string;
|
||||||
|
/** Api token with the `D1 Database:Edit` permission. */
|
||||||
|
apiToken: string;
|
||||||
|
/** Endpoint of the api, useful for tests. */
|
||||||
|
endpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const D1_TYPES: Record<FieldType, string> = {
|
||||||
|
text: "TEXT",
|
||||||
|
int: "INTEGER",
|
||||||
|
bigint: "INTEGER",
|
||||||
|
real: "REAL",
|
||||||
|
bool: "INTEGER",
|
||||||
|
blob: "BLOB",
|
||||||
|
json: "TEXT",
|
||||||
|
uuid: "TEXT",
|
||||||
|
timestamp: "TEXT",
|
||||||
|
date: "TEXT",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const d1Dialect: SqlDialect = {
|
||||||
|
name: "sqlite",
|
||||||
|
columnType: (type) => D1_TYPES[type],
|
||||||
|
placeholder: () => "?",
|
||||||
|
supportsReturning: true,
|
||||||
|
defaultExpression: (column) => {
|
||||||
|
if (column.default === undefined) return undefined;
|
||||||
|
if (typeof column.default === "number") return String(column.default);
|
||||||
|
if (typeof column.default === "boolean") return column.default ? "1" : "0";
|
||||||
|
return `'${String(column.default).replace(/'/g, "''")}'`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface D1Response {
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
results?: Record<string, unknown>[];
|
||||||
|
success?: boolean;
|
||||||
|
meta?: { changes?: number };
|
||||||
|
}[];
|
||||||
|
errors?: { message: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class D1Driver implements SqlDriver {
|
||||||
|
readonly dialect = "sqlite" as const;
|
||||||
|
readonly supportsReturning = true;
|
||||||
|
|
||||||
|
constructor(private readonly options: D1Options) {}
|
||||||
|
|
||||||
|
/** Placeholder for the n-th parameter (1 based). */
|
||||||
|
placeholder(_index: number): string {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a statement and returns every row. */
|
||||||
|
async query<T = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const response = await this.send(sql, params);
|
||||||
|
const statement = response.result?.[0];
|
||||||
|
return (statement?.results ?? []) as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a statement and returns the number of affected rows. */
|
||||||
|
async execute(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[] = [],
|
||||||
|
): Promise<{ changes: number }> {
|
||||||
|
const response = await this.send(sql, params);
|
||||||
|
const statement = response.result?.[0];
|
||||||
|
return { changes: statement?.meta?.changes ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `fn` inside a transaction.
|
||||||
|
*
|
||||||
|
* D1 has no interactive transactions, the statements are collected and
|
||||||
|
* sent as a single batch, which is atomic.
|
||||||
|
*/
|
||||||
|
async transaction<T>(fn: (driver: SqlDriver) => Promise<T>): Promise<T> {
|
||||||
|
const buffered = new D1Buffer(this.options);
|
||||||
|
const result = await fn(buffered);
|
||||||
|
await buffered.flush();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the table of a model, if it does not exist yet. */
|
||||||
|
async createTable(
|
||||||
|
table: string,
|
||||||
|
columns: Parameters<typeof buildCreateTable>[2],
|
||||||
|
options: Parameters<typeof buildCreateTable>[3],
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(buildCreateTable(d1Dialect, table, columns, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the driver, D1 has no local resources. */
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
|
||||||
|
private async send(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[],
|
||||||
|
): Promise<D1Response> {
|
||||||
|
const endpoint =
|
||||||
|
this.options.endpoint ??
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${this.options.accountId}/d1/database/${this.options.databaseId}/query`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${this.options.apiToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sql,
|
||||||
|
params: params.map((value) => normalize(value)),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as D1Response;
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
const message = payload.errors?.[0]?.message ?? response.statusText;
|
||||||
|
throw new Error(`D1 request failed: ${message}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Driver that collects the statements of a transaction. */
|
||||||
|
class D1Buffer implements SqlDriver {
|
||||||
|
readonly dialect = "sqlite" as const;
|
||||||
|
readonly supportsReturning = true;
|
||||||
|
|
||||||
|
private readonly statements: { sql: string; params: unknown[] }[] = [];
|
||||||
|
private result: unknown;
|
||||||
|
|
||||||
|
constructor(private readonly options: D1Options) {}
|
||||||
|
|
||||||
|
placeholder(_index: number): string {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
async query<T = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
this.statements.push({ sql, params: [...params] });
|
||||||
|
this.result = [];
|
||||||
|
return [] as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[] = [],
|
||||||
|
): Promise<{ changes: number }> {
|
||||||
|
this.statements.push({ sql, params: [...params] });
|
||||||
|
return { changes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(fn: (driver: SqlDriver) => Promise<T>): Promise<T> {
|
||||||
|
return fn(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
if (this.statements.length === 0) return;
|
||||||
|
const endpoint =
|
||||||
|
this.options.endpoint ??
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${this.options.accountId}/d1/database/${this.options.databaseId}/batch`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${this.options.apiToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
statements: this.statements.map((statement) => ({
|
||||||
|
sql: statement.sql,
|
||||||
|
params: statement.params.map((value) => normalize(value)),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`D1 batch failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value: unknown): unknown {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
if (typeof value === "boolean") return value ? 1 : 0;
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === "object" && !(value instanceof Uint8Array)) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a Cloudflare D1 driver. */
|
||||||
|
export function createD1Driver(options: D1Options): D1Driver {
|
||||||
|
return new D1Driver(options);
|
||||||
|
}
|
||||||
165
src/adapters/driver.ts
Normal file
165
src/adapters/driver.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import type {
|
||||||
|
ColumnMapping,
|
||||||
|
FieldType,
|
||||||
|
TableOptions,
|
||||||
|
} from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
|
||||||
|
/** Anything able to produce a parameter placeholder. */
|
||||||
|
export interface PlaceholderSource {
|
||||||
|
placeholder(index: number): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqlDialect {
|
||||||
|
readonly name: "sqlite" | "postgres";
|
||||||
|
/** SQL type of a field. */
|
||||||
|
columnType(type: FieldType): string;
|
||||||
|
/** Placeholder for the n-th parameter (1 based). */
|
||||||
|
placeholder(index: number): string;
|
||||||
|
/** `true` when the dialect understands `RETURNING`. */
|
||||||
|
readonly supportsReturning: boolean;
|
||||||
|
/** Default value expression of a column, if any. */
|
||||||
|
defaultExpression(column: ColumnMapping): string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quotes an SQL identifier. */
|
||||||
|
export function quote(identifier: string): string {
|
||||||
|
return `"${identifier.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers a parameter and returns its placeholder. */
|
||||||
|
export function bind(
|
||||||
|
dialect: PlaceholderSource,
|
||||||
|
params: unknown[],
|
||||||
|
value: unknown,
|
||||||
|
): string {
|
||||||
|
params.push(value);
|
||||||
|
return dialect.placeholder(params.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Condition {
|
||||||
|
column: string;
|
||||||
|
operator: string;
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a `WHERE` clause from the condition groups of the query builder. */
|
||||||
|
export function buildWhere(
|
||||||
|
dialect: PlaceholderSource,
|
||||||
|
groups: { conditions: Condition[]; conjunction: "AND" | "OR" }[],
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
if (groups.length === 0) return "";
|
||||||
|
const parts = groups.map((group) => {
|
||||||
|
const conditions = group.conditions.map((condition) =>
|
||||||
|
buildCondition(dialect, condition, params),
|
||||||
|
);
|
||||||
|
if (conditions.length === 1) return conditions[0] as string;
|
||||||
|
return `(${conditions.join(` ${group.conjunction} `)})`;
|
||||||
|
});
|
||||||
|
return ` WHERE ${parts.join(" AND ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a single condition, e.g. `"age" > $1`. */
|
||||||
|
export function buildCondition(
|
||||||
|
dialect: PlaceholderSource,
|
||||||
|
condition: Condition,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
const column = quote(condition.column);
|
||||||
|
|
||||||
|
if (
|
||||||
|
condition.operator === "is null" ||
|
||||||
|
condition.operator === "is not null"
|
||||||
|
) {
|
||||||
|
return `${column} ${condition.operator.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.operator === "in" || condition.operator === "not in") {
|
||||||
|
const values = Array.isArray(condition.value)
|
||||||
|
? condition.value
|
||||||
|
: [condition.value];
|
||||||
|
if (values.length === 0) {
|
||||||
|
return condition.operator === "in" ? "1 = 0" : "1 = 1";
|
||||||
|
}
|
||||||
|
const placeholders = values
|
||||||
|
.map((value) => bind(dialect, params, value))
|
||||||
|
.join(", ");
|
||||||
|
return `${column} ${condition.operator.toUpperCase()} (${placeholders})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${column} ${condition.operator} ${bind(dialect, params, condition.value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the `CREATE TABLE` statement of a table. */
|
||||||
|
export function buildCreateTable(
|
||||||
|
dialect: SqlDialect,
|
||||||
|
table: string,
|
||||||
|
columns: ColumnMapping[],
|
||||||
|
options: TableOptions,
|
||||||
|
): string {
|
||||||
|
const definitions: string[] = [];
|
||||||
|
|
||||||
|
for (const column of columns) {
|
||||||
|
const parts = [quote(column.column), dialect.columnType(column.type)];
|
||||||
|
if (column.primary) parts.push("PRIMARY KEY");
|
||||||
|
if (column.unique) parts.push("UNIQUE");
|
||||||
|
if (!column.nullable && !column.primary) parts.push("NOT NULL");
|
||||||
|
const defaultExpression = dialect.defaultExpression(column);
|
||||||
|
if (defaultExpression) parts.push(`DEFAULT ${defaultExpression}`);
|
||||||
|
if (column.references) {
|
||||||
|
const target = quote(column.references.table);
|
||||||
|
const targetColumn = column.references.column
|
||||||
|
? `${target}(${quote(column.references.column)})`
|
||||||
|
: undefined;
|
||||||
|
parts.push(
|
||||||
|
`REFERENCES ${targetColumn ?? target}${column.references.onDelete ? ` ON DELETE ${column.references.onDelete}` : ""}${
|
||||||
|
column.references.onUpdate
|
||||||
|
? ` ON UPDATE ${column.references.onUpdate}`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
definitions.push(parts.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.timestamps) {
|
||||||
|
definitions.push(
|
||||||
|
`${quote("created_at")} ${dialect.columnType("timestamp")} NOT NULL DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
);
|
||||||
|
definitions.push(
|
||||||
|
`${quote("updated_at")} ${dialect.columnType("timestamp")} NOT NULL DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryKey = options.primaryKey
|
||||||
|
? Array.isArray(options.primaryKey)
|
||||||
|
? options.primaryKey
|
||||||
|
: [options.primaryKey]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (primaryKey && primaryKey.length > 0) {
|
||||||
|
definitions.push(`PRIMARY KEY (${primaryKey.map(quote).join(", ")})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ifNotExists = options.ifNotExists === false ? "" : "IF NOT EXISTS ";
|
||||||
|
const suffix = options.suffix ? ` ${options.suffix}` : "";
|
||||||
|
|
||||||
|
return `CREATE TABLE ${ifNotExists}${quote(table)} (\n ${definitions.join(",\n ")}\n)${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalises a value before it is handed to the driver. */
|
||||||
|
export function normalizeValue(value: unknown): unknown {
|
||||||
|
if (value === undefined) return null;
|
||||||
|
if (value instanceof Date) return value;
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "bigint") return value;
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Buffer.isBuffer(value) &&
|
||||||
|
!(value instanceof Uint8Array)
|
||||||
|
) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
118
src/adapters/pg.ts
Normal file
118
src/adapters/pg.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { Pool, type PoolClient, type PoolConfig } from "pg";
|
||||||
|
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import type { FieldType } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import { buildCreateTable, type SqlDialect } from "./driver.ts";
|
||||||
|
|
||||||
|
export type PostgresOptions = PoolConfig | string;
|
||||||
|
|
||||||
|
const POSTGRES_TYPES: Record<FieldType, string> = {
|
||||||
|
text: "TEXT",
|
||||||
|
int: "INTEGER",
|
||||||
|
bigint: "BIGINT",
|
||||||
|
real: "DOUBLE PRECISION",
|
||||||
|
bool: "BOOLEAN",
|
||||||
|
blob: "BYTEA",
|
||||||
|
json: "JSONB",
|
||||||
|
uuid: "UUID",
|
||||||
|
timestamp: "TIMESTAMPTZ",
|
||||||
|
date: "DATE",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postgresDialect: SqlDialect = {
|
||||||
|
name: "postgres",
|
||||||
|
columnType: (type) => POSTGRES_TYPES[type],
|
||||||
|
placeholder: (index) => `$${index}`,
|
||||||
|
supportsReturning: true,
|
||||||
|
defaultExpression: (column) => {
|
||||||
|
if (column.default === undefined) return undefined;
|
||||||
|
if (typeof column.default === "number") return String(column.default);
|
||||||
|
if (typeof column.default === "boolean")
|
||||||
|
return column.default ? "TRUE" : "FALSE";
|
||||||
|
if (typeof column.default === "object")
|
||||||
|
return JSON.stringify(column.default);
|
||||||
|
return `'${String(column.default).replace(/'/g, "''")}'`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PostgresDriver implements SqlDriver {
|
||||||
|
readonly dialect = "postgres" as const;
|
||||||
|
/** `true` when the dialect understands `RETURNING`. */
|
||||||
|
readonly supportsReturning = true;
|
||||||
|
private readonly pool: Pool | undefined;
|
||||||
|
private readonly client?: PoolClient;
|
||||||
|
|
||||||
|
constructor(options: PostgresOptions, client?: PoolClient) {
|
||||||
|
this.pool = client
|
||||||
|
? undefined
|
||||||
|
: new Pool(
|
||||||
|
typeof options === "string" ? { connectionString: options } : options,
|
||||||
|
);
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get runner(): Pool | PoolClient {
|
||||||
|
return this.client ?? (this.pool as Pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Placeholder for the n-th parameter (1 based). */
|
||||||
|
placeholder(index: number): string {
|
||||||
|
return `$${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a statement and returns every row. */
|
||||||
|
async query<T = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const result = await this.runner.query(sql, [...params] as unknown[]);
|
||||||
|
return result.rows as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a statement and returns the number of affected rows. */
|
||||||
|
async execute(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[] = [],
|
||||||
|
): Promise<{ changes: number }> {
|
||||||
|
const result = await this.runner.query(sql, [...params] as unknown[]);
|
||||||
|
return { changes: result.rowCount ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `fn` inside a transaction on a dedicated connection. */
|
||||||
|
async transaction<T>(fn: (driver: SqlDriver) => Promise<T>): Promise<T> {
|
||||||
|
const client = this.client ?? (await (this.pool as Pool).connect());
|
||||||
|
const ownsClient = !this.client;
|
||||||
|
const driver = new PostgresDriver({}, client);
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const result = await fn(driver);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (ownsClient) client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the table of a model, if it does not exist yet. */
|
||||||
|
async createTable(
|
||||||
|
table: string,
|
||||||
|
columns: Parameters<typeof buildCreateTable>[2],
|
||||||
|
options: Parameters<typeof buildCreateTable>[3],
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
buildCreateTable(postgresDialect, table, columns, options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the pool. */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.pool) await this.pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a PostgreSQL driver. */
|
||||||
|
export function createPostgresDriver(options: PostgresOptions): PostgresDriver {
|
||||||
|
return new PostgresDriver(options);
|
||||||
|
}
|
||||||
130
src/adapters/sqlite.ts
Normal file
130
src/adapters/sqlite.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { Database as SqliteDatabase, type SQLQueryBindings } from "bun:sqlite";
|
||||||
|
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import type { FieldType } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import { buildCreateTable, normalizeValue, type SqlDialect } from "./driver.ts";
|
||||||
|
|
||||||
|
export interface SqliteOptions {
|
||||||
|
/** Path of the database file, `:memory:` for an in-memory database. */
|
||||||
|
path?: string;
|
||||||
|
/** Opens the database in read only mode. */
|
||||||
|
readonly?: boolean;
|
||||||
|
/** `false` disables the automatic creation of the database file. */
|
||||||
|
create?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQLITE_TYPES: Record<FieldType, string> = {
|
||||||
|
text: "TEXT",
|
||||||
|
int: "INTEGER",
|
||||||
|
bigint: "INTEGER",
|
||||||
|
real: "REAL",
|
||||||
|
bool: "INTEGER",
|
||||||
|
blob: "BLOB",
|
||||||
|
json: "TEXT",
|
||||||
|
uuid: "TEXT",
|
||||||
|
timestamp: "TEXT",
|
||||||
|
date: "TEXT",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sqliteDialect: SqlDialect = {
|
||||||
|
name: "sqlite",
|
||||||
|
columnType: (type) => SQLITE_TYPES[type],
|
||||||
|
placeholder: () => "?",
|
||||||
|
supportsReturning: true,
|
||||||
|
defaultExpression: (column) => {
|
||||||
|
if (column.default === undefined) return undefined;
|
||||||
|
if (typeof column.default === "number") return String(column.default);
|
||||||
|
if (typeof column.default === "boolean") return column.default ? "1" : "0";
|
||||||
|
return `'${String(column.default).replace(/'/g, "''")}'`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Converts a value into a representation SQLite understands. */
|
||||||
|
function toSqliteValue(value: unknown): unknown {
|
||||||
|
const normalized = normalizeValue(value);
|
||||||
|
if (normalized === null || normalized === undefined) return null;
|
||||||
|
if (typeof normalized === "boolean") return normalized ? 1 : 0;
|
||||||
|
if (normalized instanceof Date) return normalized.toISOString();
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SqliteDriver implements SqlDriver {
|
||||||
|
readonly dialect = "sqlite" as const;
|
||||||
|
/** `true` when the dialect understands `RETURNING`. */
|
||||||
|
readonly supportsReturning = true;
|
||||||
|
private readonly database: SqliteDatabase;
|
||||||
|
private depth = 0;
|
||||||
|
|
||||||
|
constructor(options: SqliteOptions = {}) {
|
||||||
|
this.database = new SqliteDatabase(options.path ?? ":memory:", {
|
||||||
|
readonly: options.readonly ?? false,
|
||||||
|
create: options.create ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Placeholder for the n-th parameter (1 based). */
|
||||||
|
placeholder(_index: number): string {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a statement and returns every row. */
|
||||||
|
async query<T = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const statement = this.database.query(sql);
|
||||||
|
return statement.all(
|
||||||
|
...(params.map(toSqliteValue) as SQLQueryBindings[]),
|
||||||
|
) as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a statement and returns the number of affected rows. */
|
||||||
|
async execute(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[] = [],
|
||||||
|
): Promise<{ changes: number }> {
|
||||||
|
const statement = this.database.query(sql);
|
||||||
|
const info = statement.run(
|
||||||
|
...(params.map(toSqliteValue) as SQLQueryBindings[]),
|
||||||
|
);
|
||||||
|
return { changes: info.changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `fn` inside a transaction, nested calls use savepoints. */
|
||||||
|
async transaction<T>(fn: (driver: SqlDriver) => Promise<T>): Promise<T> {
|
||||||
|
const nested = this.depth > 0;
|
||||||
|
const savepoint = `muddle_sp_${this.depth}`;
|
||||||
|
await this.execute(nested ? `SAVEPOINT ${savepoint}` : "BEGIN");
|
||||||
|
this.depth += 1;
|
||||||
|
try {
|
||||||
|
const result = await fn(this);
|
||||||
|
this.depth -= 1;
|
||||||
|
await this.execute(nested ? `RELEASE ${savepoint}` : "COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.depth -= 1;
|
||||||
|
await this.execute(nested ? `ROLLBACK TO ${savepoint}` : "ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the table of a model, if it does not exist yet. */
|
||||||
|
async createTable(
|
||||||
|
table: string,
|
||||||
|
columns: Parameters<typeof buildCreateTable>[2],
|
||||||
|
options: Parameters<typeof buildCreateTable>[3],
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
buildCreateTable(sqliteDialect, table, columns, options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the database. */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a SQLite driver. */
|
||||||
|
export function createSqliteDriver(options: SqliteOptions = {}): SqliteDriver {
|
||||||
|
return new SqliteDriver(options);
|
||||||
|
}
|
||||||
55
src/database.ts
Normal file
55
src/database.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import type {
|
||||||
|
ColumnMapping,
|
||||||
|
SqlDriver,
|
||||||
|
TableOptions,
|
||||||
|
} from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import type { TableClass } from "muddle/contracts/typegen/orm/table.ts";
|
||||||
|
import { getTableMetadata } from "./decorators/Table.ts";
|
||||||
|
import { Repository } from "./repository.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database.
|
||||||
|
*
|
||||||
|
* Owns the driver, keeps track of the registered models and creates the
|
||||||
|
* tables of those models.
|
||||||
|
*/
|
||||||
|
export class Database {
|
||||||
|
private readonly models = new Map<string, TableClass<object>>();
|
||||||
|
|
||||||
|
constructor(readonly driver: SqlDriver) {}
|
||||||
|
|
||||||
|
/** Registers a model and returns its repository. */
|
||||||
|
model<T extends object>(model: TableClass<T>): Repository<T> {
|
||||||
|
const metadata = getTableMetadata(model as unknown as object);
|
||||||
|
this.models.set(metadata.name, model as unknown as TableClass<object>);
|
||||||
|
return new Repository<T>(this.driver, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the tables of every registered model. */
|
||||||
|
async migrate(): Promise<void> {
|
||||||
|
for (const model of this.models.values()) {
|
||||||
|
const metadata = getTableMetadata(model as unknown as object);
|
||||||
|
const columns = [...metadata.fields.values()];
|
||||||
|
const driver = this.driver as SqlDriver & {
|
||||||
|
createTable?: (
|
||||||
|
table: string,
|
||||||
|
columns: ColumnMapping[],
|
||||||
|
options: TableOptions,
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
|
if (driver.createTable) {
|
||||||
|
await driver.createTable(metadata.name, columns, metadata.options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `fn` inside a transaction. */
|
||||||
|
async transaction<T>(fn: (database: Database) => Promise<T>): Promise<T> {
|
||||||
|
return this.driver.transaction(async (driver) => fn(new Database(driver)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the underlying driver. */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.driver.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/decorators/Field.ts
Normal file
40
src/decorators/Field.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { defineMetadata, getMetadata } from "muddle/metadata";
|
||||||
|
import type { FieldOptions } from "muddle/contracts/typegen/orm/field.ts";
|
||||||
|
import type { FieldDescriptor } from "muddle/contracts/typegen/orm/field.ts";
|
||||||
|
|
||||||
|
export const FIELD_METADATA_KEY = "muddle:orm:fields";
|
||||||
|
|
||||||
|
/** Converts a property name into the default column name. */
|
||||||
|
export function toColumnName(property: string): string {
|
||||||
|
return property
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declares the column backing a property. */
|
||||||
|
export function Field<Value = any>(
|
||||||
|
options: FieldOptions<Value>,
|
||||||
|
): PropertyDecorator {
|
||||||
|
return (target, propertyKey) => {
|
||||||
|
const constructor = target.constructor as object;
|
||||||
|
const fields = getFields(constructor);
|
||||||
|
const property = String(propertyKey);
|
||||||
|
fields.set(property, {
|
||||||
|
property,
|
||||||
|
column: options.column ?? toColumnName(property),
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the fields declared on a model class. */
|
||||||
|
export function getFields(target: object): Map<string, FieldDescriptor> {
|
||||||
|
const existing = getMetadata(FIELD_METADATA_KEY, target) as
|
||||||
|
| Map<string, FieldDescriptor>
|
||||||
|
| undefined;
|
||||||
|
if (existing) return existing;
|
||||||
|
const fields = new Map<string, FieldDescriptor>();
|
||||||
|
defineMetadata(FIELD_METADATA_KEY, fields, target);
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
42
src/decorators/Hydrate.ts
Normal file
42
src/decorators/Hydrate.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { defineMetadata, getMetadata } from "muddle/metadata";
|
||||||
|
|
||||||
|
export const HYDRATE_METADATA_KEY = "muddle:orm:hydrated";
|
||||||
|
|
||||||
|
export interface HydrateOptions<Value = any> {
|
||||||
|
/** Computes the value from the raw database row. */
|
||||||
|
decode?: (row: Record<string, unknown>) => Value;
|
||||||
|
/** Column the value is derived from, handed to `decode` as second argument. */
|
||||||
|
column?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrateDescriptor {
|
||||||
|
property: string;
|
||||||
|
options: HydrateOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declares a hydrated (computed) property. */
|
||||||
|
export function Hydrate<Value = any>(
|
||||||
|
options: HydrateOptions<Value> = {},
|
||||||
|
): PropertyDecorator {
|
||||||
|
return (target, propertyKey) => {
|
||||||
|
const constructor = target.constructor as object;
|
||||||
|
const hydrated = getHydratedProperties(constructor);
|
||||||
|
hydrated.set(String(propertyKey), {
|
||||||
|
property: String(propertyKey),
|
||||||
|
options: options as HydrateOptions,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the hydrated properties declared on a model class. */
|
||||||
|
export function getHydratedProperties(
|
||||||
|
target: object,
|
||||||
|
): Map<string, HydrateDescriptor> {
|
||||||
|
const existing = getMetadata(HYDRATE_METADATA_KEY, target) as
|
||||||
|
| Map<string, HydrateDescriptor>
|
||||||
|
| undefined;
|
||||||
|
if (existing) return existing;
|
||||||
|
const hydrated = new Map<string, HydrateDescriptor>();
|
||||||
|
defineMetadata(HYDRATE_METADATA_KEY, hydrated, target);
|
||||||
|
return hydrated;
|
||||||
|
}
|
||||||
85
src/decorators/Table.ts
Normal file
85
src/decorators/Table.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { defineMetadata, getMetadata } from "muddle/metadata";
|
||||||
|
import type { ColumnMapping } from "muddle/contracts/typegen/orm/field.ts";
|
||||||
|
import type { TableOptions } from "muddle/contracts/typegen/orm/table.ts";
|
||||||
|
import { getFields } from "./Field.ts";
|
||||||
|
import { getHydratedProperties } from "./Hydrate.ts";
|
||||||
|
|
||||||
|
export const TABLE_METADATA_KEY = "muddle:orm:table";
|
||||||
|
|
||||||
|
export interface TableMetadata {
|
||||||
|
/** Table name. */
|
||||||
|
name: string;
|
||||||
|
options: TableOptions;
|
||||||
|
/** Persisted fields, in declaration order. */
|
||||||
|
fields: Map<string, ColumnMapping>;
|
||||||
|
/** Raw field descriptors, including the encode/decode transformations. */
|
||||||
|
descriptors: Map<
|
||||||
|
string,
|
||||||
|
{ property: string; column: string; options: unknown }
|
||||||
|
>;
|
||||||
|
/** Computed properties, hydrated but never persisted. */
|
||||||
|
hydrated: Map<string, { property: string; options: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declares the table backing a model class. */
|
||||||
|
export function Table(
|
||||||
|
name: string,
|
||||||
|
options: TableOptions = {},
|
||||||
|
): ClassDecorator {
|
||||||
|
return (target) => {
|
||||||
|
const constructor = target as unknown as object;
|
||||||
|
const descriptors = getFields(constructor);
|
||||||
|
const hydrated = getHydratedProperties(constructor);
|
||||||
|
|
||||||
|
const fields = new Map<string, ColumnMapping>();
|
||||||
|
for (const [property, descriptor] of descriptors) {
|
||||||
|
const fieldOptions = descriptor.options as {
|
||||||
|
type: ColumnMapping["type"];
|
||||||
|
primary?: boolean;
|
||||||
|
unique?: boolean;
|
||||||
|
nullable?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
references?: ColumnMapping["references"];
|
||||||
|
};
|
||||||
|
fields.set(property, {
|
||||||
|
property,
|
||||||
|
column: descriptor.column,
|
||||||
|
type: fieldOptions.type,
|
||||||
|
nullable: fieldOptions.nullable === true,
|
||||||
|
primary: fieldOptions.primary === true,
|
||||||
|
unique: fieldOptions.unique === true,
|
||||||
|
default: fieldOptions.default,
|
||||||
|
references: fieldOptions.references,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: TableMetadata = {
|
||||||
|
name,
|
||||||
|
options,
|
||||||
|
fields,
|
||||||
|
descriptors: descriptors as TableMetadata["descriptors"],
|
||||||
|
hydrated: hydrated as TableMetadata["hydrated"],
|
||||||
|
};
|
||||||
|
|
||||||
|
defineMetadata(TABLE_METADATA_KEY, metadata, constructor);
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the table metadata of a model class. */
|
||||||
|
export function getTableMetadata(target: object): TableMetadata {
|
||||||
|
const metadata = getMetadata(TABLE_METADATA_KEY, target) as
|
||||||
|
| TableMetadata
|
||||||
|
| undefined;
|
||||||
|
if (!metadata) {
|
||||||
|
throw new Error(
|
||||||
|
`${(target as { name?: string }).name ?? "model"} is not decorated with @Table()`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `true` when the class is a table model. */
|
||||||
|
export function isTable(target: object): boolean {
|
||||||
|
return getMetadata(TABLE_METADATA_KEY, target) !== undefined;
|
||||||
|
}
|
||||||
81
src/hydration.ts
Normal file
81
src/hydration.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { getTableMetadata } from "./decorators/Table.ts";
|
||||||
|
import type { TableClass } from "muddle/contracts/typegen/orm/table.ts";
|
||||||
|
|
||||||
|
function createInstance<T>(model: TableClass<T>): T {
|
||||||
|
try {
|
||||||
|
return new (model as unknown as new () => T)();
|
||||||
|
} catch {
|
||||||
|
return Object.create(model.prototype) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hydrates a raw database row into a model instance. */
|
||||||
|
export function hydrateRow<T>(
|
||||||
|
model: TableClass<T>,
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
): T {
|
||||||
|
const metadata = getTableMetadata(model as unknown as object);
|
||||||
|
const instance = createInstance(model);
|
||||||
|
const target = instance as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const [property, descriptor] of metadata.descriptors) {
|
||||||
|
const raw = row[descriptor.column];
|
||||||
|
const options = descriptor.options as {
|
||||||
|
decode?: (value: unknown) => unknown;
|
||||||
|
};
|
||||||
|
target[property] = options.decode ? options.decode(raw) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [property, descriptor] of metadata.hydrated) {
|
||||||
|
const options = descriptor.options as {
|
||||||
|
decode?: (row: Record<string, unknown>) => unknown;
|
||||||
|
column?: string;
|
||||||
|
};
|
||||||
|
let value: unknown;
|
||||||
|
if (options.decode) {
|
||||||
|
value = options.decode(row);
|
||||||
|
} else {
|
||||||
|
const getter = Object.getOwnPropertyDescriptor(
|
||||||
|
model.prototype,
|
||||||
|
property,
|
||||||
|
)?.get;
|
||||||
|
value = getter ? getter.call(instance) : undefined;
|
||||||
|
}
|
||||||
|
Object.defineProperty(target, property, {
|
||||||
|
value,
|
||||||
|
enumerable: true,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encodes a model value into the values written to the database. */
|
||||||
|
export function encodeRow<T>(
|
||||||
|
model: TableClass<T>,
|
||||||
|
data: Partial<T> | Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const metadata = getTableMetadata(model as unknown as object);
|
||||||
|
const encoded: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
||||||
|
const descriptor = metadata.descriptors.get(key);
|
||||||
|
if (!descriptor) continue;
|
||||||
|
const options = descriptor.options as {
|
||||||
|
encode?: (value: unknown) => unknown;
|
||||||
|
};
|
||||||
|
encoded[descriptor.column] = options.encode ? options.encode(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hydrates a list of raw rows. */
|
||||||
|
export function hydrateRows<T>(
|
||||||
|
model: TableClass<T>,
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
): T[] {
|
||||||
|
return rows.map((row) => hydrateRow(model, row));
|
||||||
|
}
|
||||||
68
src/index.ts
Normal file
68
src/index.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { PoolConfig } from "pg";
|
||||||
|
import { createD1Driver, D1Driver } from "./adapters/d1.ts";
|
||||||
|
import { createPostgresDriver, PostgresDriver } from "./adapters/pg.ts";
|
||||||
|
import { createSqliteDriver } from "./adapters/sqlite.ts";
|
||||||
|
import { Database } from "./database.ts";
|
||||||
|
|
||||||
|
export * from "./adapters/d1.ts";
|
||||||
|
export * from "./adapters/driver.ts";
|
||||||
|
export * from "./adapters/pg.ts";
|
||||||
|
export * from "./adapters/sqlite.ts";
|
||||||
|
export * from "./database.ts";
|
||||||
|
export * from "./decorators/Field.ts";
|
||||||
|
export * from "./decorators/Hydrate.ts";
|
||||||
|
export * from "./decorators/Table.ts";
|
||||||
|
export * from "./hydration.ts";
|
||||||
|
export * from "./repository.ts";
|
||||||
|
export * from "./migrations.ts";
|
||||||
|
|
||||||
|
export interface SqliteDatabaseOptions {
|
||||||
|
dialect: "sqlite";
|
||||||
|
/** Path of the database file, `:memory:` for an in-memory database. */
|
||||||
|
path?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostgresDatabaseOptions {
|
||||||
|
dialect: "postgres";
|
||||||
|
/** Connection string, e.g. `postgres://user:pass@localhost:5432/db`. */
|
||||||
|
url?: string;
|
||||||
|
/** Connection options, used when no url is given. */
|
||||||
|
connection?: PoolConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface D1DatabaseOptions {
|
||||||
|
dialect: "d1";
|
||||||
|
/** Account the database belongs to. */
|
||||||
|
accountId: string;
|
||||||
|
/** Identifier of the database. */
|
||||||
|
databaseId: string;
|
||||||
|
/** Api token of the account. */
|
||||||
|
apiToken: string;
|
||||||
|
/** Endpoint of the api, useful for tests. */
|
||||||
|
endpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a database backed by SQLite or PostgreSQL. */
|
||||||
|
export function createDatabase(
|
||||||
|
options: SqliteDatabaseOptions | PostgresDatabaseOptions | D1DatabaseOptions,
|
||||||
|
): Database {
|
||||||
|
if (options.dialect === "sqlite") {
|
||||||
|
return new Database(
|
||||||
|
createSqliteDriver({ path: options.path, readonly: options.readonly }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.dialect === "d1") {
|
||||||
|
const driver: D1Driver = createD1Driver({
|
||||||
|
accountId: options.accountId,
|
||||||
|
databaseId: options.databaseId,
|
||||||
|
apiToken: options.apiToken,
|
||||||
|
endpoint: options.endpoint,
|
||||||
|
});
|
||||||
|
return new Database(driver);
|
||||||
|
}
|
||||||
|
const driver: PostgresDriver = options.url
|
||||||
|
? createPostgresDriver(options.url)
|
||||||
|
: createPostgresDriver(options.connection ?? {});
|
||||||
|
return new Database(driver);
|
||||||
|
}
|
||||||
43
src/migrations.ts
Normal file
43
src/migrations.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { TableClass } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import { getTableMetadata } from "./decorators/Table.ts";
|
||||||
|
|
||||||
|
export interface SchemaColumn { name: string; type: string; nullable: boolean }
|
||||||
|
export interface SchemaTable { name: string; columns: SchemaColumn[] }
|
||||||
|
export interface Migration { up: string[]; down: string[] }
|
||||||
|
|
||||||
|
export function schemaOf(tables: TableClass[]): SchemaTable[] {
|
||||||
|
return tables.map((table) => {
|
||||||
|
const metadata = getTableMetadata(table);
|
||||||
|
if (!metadata) throw new Error(`Missing @Table metadata for ${table.name}`);
|
||||||
|
return { name: metadata.name, columns: [...metadata.fields.values()].map((field) => ({ name: field.column, type: field.type, nullable: field.nullable })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffSchema(previous: SchemaTable[], next: SchemaTable[]): Migration {
|
||||||
|
const oldTables = new Map(previous.map((table) => [table.name, table]));
|
||||||
|
const nextTables = new Map(next.map((table) => [table.name, table]));
|
||||||
|
const up: string[] = [];
|
||||||
|
const down: string[] = [];
|
||||||
|
for (const table of next) {
|
||||||
|
const old = oldTables.get(table.name);
|
||||||
|
if (!old) {
|
||||||
|
up.push(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`);
|
||||||
|
down.unshift(`DROP TABLE ${table.name};`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const oldColumns = new Map(old.columns.map((column) => [column.name, column]));
|
||||||
|
for (const column of table.columns) if (!oldColumns.has(column.name)) {
|
||||||
|
up.push(`ALTER TABLE ${table.name} ADD COLUMN ${columnSql(column)};`);
|
||||||
|
down.unshift(`ALTER TABLE ${table.name} DROP COLUMN ${column.name};`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const table of previous) if (!nextTables.has(table.name)) {
|
||||||
|
up.push(`DROP TABLE ${table.name};`);
|
||||||
|
down.unshift(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`);
|
||||||
|
}
|
||||||
|
return { up, down };
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnSql(column: SchemaColumn): string {
|
||||||
|
return `${column.name} ${column.type.toUpperCase()}${column.nullable ? "" : " NOT NULL"}`;
|
||||||
|
}
|
||||||
281
src/repository.ts
Normal file
281
src/repository.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import type {
|
||||||
|
ColumnName,
|
||||||
|
OrderDirection,
|
||||||
|
Repository as RepositoryContract,
|
||||||
|
WhereCondition,
|
||||||
|
WhereInput,
|
||||||
|
WhereOperator,
|
||||||
|
} from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import { WHERE_OPERATORS } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
import type { TableClass } from "muddle/contracts/typegen/orm/table.ts";
|
||||||
|
import { getTableMetadata } from "./decorators/Table.ts";
|
||||||
|
import { encodeRow, hydrateRow, hydrateRows } from "./hydration.ts";
|
||||||
|
import { buildWhere, quote, type Condition } from "./adapters/driver.ts";
|
||||||
|
|
||||||
|
interface QueryState {
|
||||||
|
groups: { conditions: Condition[]; conjunction: "AND" | "OR" }[];
|
||||||
|
order: { column: string; direction: OrderDirection }[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
select?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_STATE: QueryState = { groups: [], order: [] };
|
||||||
|
|
||||||
|
function isOperator(value: unknown): value is WhereOperator {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(WHERE_OPERATORS as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Repository<T extends object> implements RepositoryContract<T> {
|
||||||
|
constructor(
|
||||||
|
private readonly driver: SqlDriver,
|
||||||
|
private readonly model: TableClass<T>,
|
||||||
|
private readonly state: QueryState = EMPTY_STATE,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get table(): string {
|
||||||
|
return getTableMetadata(this.model as unknown as object).name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clone(state: Partial<QueryState>): this {
|
||||||
|
return new Repository(this.driver, this.model, {
|
||||||
|
...this.state,
|
||||||
|
...state,
|
||||||
|
}) as unknown as this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restricts the selected columns. */
|
||||||
|
select(...columns: ColumnName<T>[]): this {
|
||||||
|
return this.clone({
|
||||||
|
select: columns.length > 0 ? [...columns] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a condition. */
|
||||||
|
where(conditions: WhereCondition[]): this;
|
||||||
|
where(column: ColumnName<T>, value: unknown): this;
|
||||||
|
where(column: ColumnName<T>, operator: WhereOperator, value?: unknown): this;
|
||||||
|
where(
|
||||||
|
columnOrConditions: ColumnName<T> | WhereCondition[],
|
||||||
|
operatorOrValue?: WhereOperator | unknown,
|
||||||
|
value?: unknown,
|
||||||
|
): this {
|
||||||
|
if (Array.isArray(columnOrConditions)) {
|
||||||
|
return this.and(columnOrConditions);
|
||||||
|
}
|
||||||
|
const column = String(columnOrConditions);
|
||||||
|
if (isOperator(operatorOrValue)) {
|
||||||
|
return this.clone({
|
||||||
|
groups: [
|
||||||
|
...this.state.groups,
|
||||||
|
{
|
||||||
|
conditions: [{ column, operator: operatorOrValue, value }],
|
||||||
|
conjunction: "AND",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.clone({
|
||||||
|
groups: [
|
||||||
|
...this.state.groups,
|
||||||
|
{
|
||||||
|
conditions: [{ column, operator: "=", value: operatorOrValue }],
|
||||||
|
conjunction: "AND",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a group of conditions combined with `AND`. */
|
||||||
|
and(conditions: WhereCondition[]): this {
|
||||||
|
return this.clone({
|
||||||
|
groups: [
|
||||||
|
...this.state.groups,
|
||||||
|
{ conditions: [...conditions], conjunction: "AND" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a group of conditions combined with `OR`. */
|
||||||
|
or(conditions: WhereCondition[]): this {
|
||||||
|
return this.clone({
|
||||||
|
groups: [
|
||||||
|
...this.state.groups,
|
||||||
|
{ conditions: [...conditions], conjunction: "OR" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orders the result set. */
|
||||||
|
orderBy(column: ColumnName<T>, direction: OrderDirection = "asc"): this {
|
||||||
|
return this.clone({
|
||||||
|
order: [...this.state.order, { column: String(column), direction }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limits the result set. */
|
||||||
|
limit(count: number): this {
|
||||||
|
return this.clone({ limit: count });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skips rows. */
|
||||||
|
offset(count: number): this {
|
||||||
|
return this.clone({ offset: count });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a raw statement and returns the rows.
|
||||||
|
*
|
||||||
|
* The rows are not hydrated, the method is meant for the statements the
|
||||||
|
* repository does not cover.
|
||||||
|
*/
|
||||||
|
async query<R = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: readonly unknown[] = [],
|
||||||
|
): Promise<R[]> {
|
||||||
|
return this.driver.query<R>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Executes the query and hydrates every row. */
|
||||||
|
async all(): Promise<T[]> {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const columns = this.state.select
|
||||||
|
? this.state.select
|
||||||
|
.map((column) => quote(this.columnOf(column)))
|
||||||
|
.join(", ")
|
||||||
|
: "*";
|
||||||
|
const where = buildWhere(this.driver, this.state.groups, params);
|
||||||
|
const order = this.buildOrder();
|
||||||
|
const limit = this.buildLimit(params);
|
||||||
|
const sql = `SELECT ${columns} FROM ${quote(this.table)}${where}${order}${limit}`;
|
||||||
|
const rows = await this.driver.query<Record<string, unknown>>(sql, params);
|
||||||
|
return hydrateRows(this.model, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alias of {@link all}. */
|
||||||
|
async find(where?: WhereInput<T>): Promise<T[]> {
|
||||||
|
const repository = where ? this.applyWhereInput(where) : this;
|
||||||
|
return repository.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Executes the query and hydrates the first row. */
|
||||||
|
async first(): Promise<T | null> {
|
||||||
|
const rows = await this.limit(1).all();
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alias of {@link first}. */
|
||||||
|
async findOne(where?: WhereInput<T>): Promise<T | null> {
|
||||||
|
const repository = where ? this.applyWhereInput(where) : this;
|
||||||
|
return repository.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts the rows matching the current conditions. */
|
||||||
|
async count(): Promise<number> {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const where = buildWhere(this.driver, this.state.groups, params);
|
||||||
|
const sql = `SELECT COUNT(*) AS ${quote("count")} FROM ${quote(this.table)}${where}`;
|
||||||
|
const rows = await this.driver.query<{ count: string | number }>(
|
||||||
|
sql,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const value = rows[0]?.count ?? 0;
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserts a row, encoding every value before the commit. */
|
||||||
|
async create(data: Partial<T>): Promise<T> {
|
||||||
|
const encoded = encodeRow(this.model, data);
|
||||||
|
const columns = Object.keys(encoded);
|
||||||
|
if (columns.length === 0) {
|
||||||
|
throw new Error(`Cannot insert an empty row into ${this.table}`);
|
||||||
|
}
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const placeholders = columns.map((column) => {
|
||||||
|
params.push(encoded[column]);
|
||||||
|
return this.driver.placeholder(params.length);
|
||||||
|
});
|
||||||
|
const returning = this.driver.supportsReturning ? " RETURNING *" : "";
|
||||||
|
const sql = `INSERT INTO ${quote(this.table)} (${columns.map(quote).join(", ")}) VALUES (${placeholders.join(", ")})${returning}`;
|
||||||
|
const rows = await this.driver.query<Record<string, unknown>>(sql, params);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(`Insert into ${this.table} did not return a row`);
|
||||||
|
}
|
||||||
|
return hydrateRow(this.model, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates the matching rows, encoding every value before the commit. */
|
||||||
|
async update(data: Partial<T>): Promise<number> {
|
||||||
|
const encoded = encodeRow(this.model, data);
|
||||||
|
const columns = Object.keys(encoded);
|
||||||
|
if (columns.length === 0) return 0;
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const assignments = columns.map((column) => {
|
||||||
|
params.push(encoded[column]);
|
||||||
|
return `${quote(column)} = ${this.driver.placeholder(params.length)}`;
|
||||||
|
});
|
||||||
|
const where = buildWhere(this.driver, this.state.groups, params);
|
||||||
|
const sql = `UPDATE ${quote(this.table)} SET ${assignments.join(", ")}${where}`;
|
||||||
|
const result = await this.driver.execute(sql, params);
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the matching rows. */
|
||||||
|
async delete(): Promise<number> {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const where = buildWhere(this.driver, this.state.groups, params);
|
||||||
|
const sql = `DELETE FROM ${quote(this.table)}${where}`;
|
||||||
|
const result = await this.driver.execute(sql, params);
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get dialect(): SqlDriver["dialect"] {
|
||||||
|
return this.driver.dialect;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyWhereInput(where: WhereInput<T>): this {
|
||||||
|
if (Array.isArray(where)) {
|
||||||
|
return this.and(where);
|
||||||
|
}
|
||||||
|
const conditions: WhereCondition[] = Object.entries(where).map(
|
||||||
|
([column, value]) => ({
|
||||||
|
column,
|
||||||
|
operator: "=",
|
||||||
|
value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return this.and(conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private columnOf(column: string): string {
|
||||||
|
const metadata = getTableMetadata(this.model as unknown as object);
|
||||||
|
return metadata.fields.get(column)?.column ?? column;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildOrder(): string {
|
||||||
|
if (this.state.order.length === 0) return "";
|
||||||
|
const parts = this.state.order.map(
|
||||||
|
(entry) =>
|
||||||
|
`${quote(this.columnOf(entry.column))} ${entry.direction.toUpperCase()}`,
|
||||||
|
);
|
||||||
|
return ` ORDER BY ${parts.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildLimit(params: unknown[]): string {
|
||||||
|
let sql = "";
|
||||||
|
if (this.state.limit !== undefined) {
|
||||||
|
params.push(this.state.limit);
|
||||||
|
sql += ` LIMIT ${this.driver.placeholder(params.length)}`;
|
||||||
|
}
|
||||||
|
if (this.state.offset !== undefined) {
|
||||||
|
params.push(this.state.offset);
|
||||||
|
sql += ` OFFSET ${this.driver.placeholder(params.length)}`;
|
||||||
|
}
|
||||||
|
return sql;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/sessions.ts
Normal file
35
src/sessions.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { SessionRecord, SessionStore } from "muddle/sessions";
|
||||||
|
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
|
||||||
|
|
||||||
|
/** Persistent sessions and atomic OAuth transaction consumption for SQLite, D1 and Postgres. */
|
||||||
|
export class DatabaseSessionStore implements SessionStore {
|
||||||
|
private constructor(private readonly driver: SqlDriver, private readonly table: string) {}
|
||||||
|
|
||||||
|
static async create(driver: SqlDriver, table = "muddle_sessions"): Promise<DatabaseSessionStore> {
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) throw new Error("Invalid session table name");
|
||||||
|
await driver.execute(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL, expires BIGINT NOT NULL)`);
|
||||||
|
return new DatabaseSessionStore(driver, table);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<SessionRecord | undefined> {
|
||||||
|
const rows = await this.driver.query<{ value: string; expires: number }>(`SELECT value, expires FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row || Number(row.expires) <= Date.now()) { await this.delete(key); return undefined; }
|
||||||
|
return { value: JSON.parse(row.value), expires: Number(row.expires) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, record: SessionRecord): Promise<void> {
|
||||||
|
const args = [key, JSON.stringify(record.value), record.expires];
|
||||||
|
await this.driver.execute(`INSERT INTO "${this.table}" (id, value, expires) VALUES (${args.map((_, index) => this.driver.placeholder(index + 1)).join(", ")}) ON CONFLICT (id) DO UPDATE SET value = excluded.value, expires = excluded.expires`, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> { await this.driver.execute(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]); }
|
||||||
|
|
||||||
|
async consume(key: string): Promise<SessionRecord | undefined> {
|
||||||
|
const rows = await this.driver.query<{ value: string; expires: number }>(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)} RETURNING value, expires`, [key]);
|
||||||
|
const row = rows[0];
|
||||||
|
return row && Number(row.expires) > Date.now() ? { value: JSON.parse(row.value), expires: Number(row.expires) } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async prune(): Promise<void> { await this.driver.execute(`DELETE FROM "${this.table}" WHERE expires <= ${this.driver.placeholder(1)}`, [Date.now()]); }
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue