From 1bde0043d04d61aae8ff039f056b0d50c9c67092 Mon Sep 17 00:00:00 2001 From: bernard-ng Date: Sun, 22 Feb 2026 01:44:25 +0200 Subject: [PATCH 01/24] refactor: optimization --- CHANGELOG.md | 8 ++ .../connection/connection-typed-fetch.test.ts | 100 ++++++++++++++++++ src/__tests__/result/result.test.ts | 12 +++ src/connection.ts | 80 ++++++++------ src/query/expression/expression-builder.ts | 27 +---- src/query/query-builder.ts | 29 +++-- src/result.ts | 14 +-- src/statement.ts | 6 +- 8 files changed, 195 insertions(+), 81 deletions(-) create mode 100644 src/__tests__/connection/connection-typed-fetch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aece27c..be07b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # @devscast/datazen # 1.0.1 - Convenience DML Parity +- Added phase 1 TypeScript-friendly typed row propagation: + - `Result` now carries a default row shape for associative fetch methods. + - `Connection.executeQuery()` and `Connection.executeQueryObject()` now return `Result`. + - `Connection` fetch helpers now expose generic return types (`fetchAssociative()`, `fetchAllAssociative()`, etc.). + - `Statement.executeQuery()` and `QueryBuilder.executeQuery()` now propagate typed row results. +- Added typed-row coverage tests: + - `src/__tests__/connection/connection-typed-fetch.test.ts` + - `src/__tests__/result/result.test.ts` - Implemented Doctrine-style convenience data manipulation methods on `Connection`: - `insert(table, data, types?)` - `update(table, data, criteria, types?)` diff --git a/src/__tests__/connection/connection-typed-fetch.test.ts b/src/__tests__/connection/connection-typed-fetch.test.ts new file mode 100644 index 0000000..c7a2c82 --- /dev/null +++ b/src/__tests__/connection/connection-typed-fetch.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { Connection } from "../../connection"; +import { + type Driver, + type DriverConnection, + type DriverExecutionResult, + type DriverQueryResult, + ParameterBindingStyle, +} from "../../driver"; +import type { + ExceptionConverter, + ExceptionConverterContext, +} from "../../driver/api/exception-converter"; +import { DriverException } from "../../exception/index"; +import type { CompiledQuery } from "../../types"; + +class NoopExceptionConverter implements ExceptionConverter { + public convert(error: unknown, context: ExceptionConverterContext): DriverException { + return new DriverException("driver error", { + cause: error, + driverName: "typed-fetch-spy", + operation: context.operation, + parameters: context.query?.parameters, + sql: context.query?.sql, + }); + } +} + +class StaticRowsConnection implements DriverConnection { + public constructor(private readonly rows: Array>) {} + + public async executeQuery(_query: CompiledQuery): Promise { + return { rows: [...this.rows] }; + } + + public async executeStatement(_query: CompiledQuery): Promise { + return { affectedRows: 0 }; + } + + public async beginTransaction(): Promise {} + public async commit(): Promise {} + public async rollBack(): Promise {} + public async getServerVersion(): Promise { + return "1.0.0-test"; + } + public async close(): Promise {} + public getNativeConnection(): unknown { + return {}; + } +} + +class StaticRowsDriver implements Driver { + public readonly name = "typed-fetch-spy"; + public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; + private readonly converter = new NoopExceptionConverter(); + + public constructor(private readonly connection: StaticRowsConnection) {} + + public async connect(_params: Record): Promise { + return this.connection; + } + + public getExceptionConverter(): ExceptionConverter { + return this.converter; + } +} + +function expectUserRow(_row: { id: number; name: string } | false): void {} + +describe("Connection typed fetch", () => { + it("propagates row type through executeQuery() and Result", async () => { + const connection = new Connection( + {}, + new StaticRowsDriver(new StaticRowsConnection([{ id: 1, name: "Alice" }])), + ); + + const result = await connection.executeQuery<{ id: number; name: string }>( + "SELECT id, name FROM users", + ); + const row = result.fetchAssociative(); + + expectUserRow(row); + expect(row).toEqual({ id: 1, name: "Alice" }); + }); + + it("supports typed fetch helpers on Connection", async () => { + const connection = new Connection( + {}, + new StaticRowsDriver(new StaticRowsConnection([{ id: 2, name: "Bob" }])), + ); + + const row = await connection.fetchAssociative<{ id: number; name: string }>( + "SELECT id, name FROM users", + ); + + expectUserRow(row); + expect(row).toEqual({ id: 2, name: "Bob" }); + }); +}); diff --git a/src/__tests__/result/result.test.ts b/src/__tests__/result/result.test.ts index e0bcec4..e919808 100644 --- a/src/__tests__/result/result.test.ts +++ b/src/__tests__/result/result.test.ts @@ -3,7 +3,19 @@ import { describe, expect, it } from "vitest"; import { NoKeyValueException } from "../../exception/index"; import { Result } from "../../result"; +function expectUserRow(_row: { id: number; name: string } | false): void {} + describe("Result", () => { + it("uses class-level row type for fetchAssociative() by default", () => { + const result = new Result<{ id: number; name: string }>({ + rows: [{ id: 1, name: "Alice" }], + }); + + const row = result.fetchAssociative(); + expectUserRow(row); + expect(row).toEqual({ id: 1, name: "Alice" }); + }); + it("fetches associative rows sequentially", () => { const result = new Result({ rows: [ diff --git a/src/connection.ts b/src/connection.ts index ee3c0fb..eb00f49 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -31,6 +31,8 @@ import type { } from "./types"; import { Type } from "./types/index"; +type DataMap = Record; + interface CompiledPositionalQuery { sql: string; parameters: unknown[]; @@ -48,14 +50,14 @@ export class Connection implements StatementExecutor { private parser: SQLParser | null = null; constructor( - private readonly params: Record, + private readonly params: DataMap, private readonly driver: Driver, private readonly configuration: Configuration = new Configuration(), ) { this.autoCommit = this.configuration.getAutoCommit(); } - public getParams(): Record { + public getParams(): DataMap { return this.params; } @@ -122,6 +124,16 @@ export class Connection implements StatementExecutor { } } + public async executeQuery( + sql: string, + params?: QueryParameters, + types?: QueryParameterTypes, + ): Promise; + public async executeQuery( + sql: string, + params?: QueryParameters, + types?: QueryParameterTypes, + ): Promise>; public async executeQuery( sql: string, params: QueryParameters = [], @@ -139,6 +151,8 @@ export class Connection implements StatementExecutor { } } + public async executeQueryObject(query: Query): Promise; + public async executeQueryObject(query: Query): Promise>; public async executeQueryObject(query: Query): Promise { const [boundParams, boundTypes] = this.normalizeParameters(query.parameters, query.types); const compiledQuery = this.compileQuery(query.sql, boundParams, boundTypes); @@ -182,73 +196,73 @@ export class Connection implements StatementExecutor { } } - public async fetchAssociative( + public async fetchAssociative( sql: string, params: QueryParameters = [], types: QueryParameterTypes = [], - ): Promise | false> { - return (await this.executeQuery(sql, params, types)).fetchAssociative(); + ): Promise { + return (await this.executeQuery(sql, params, types)).fetchAssociative(); } - public async fetchNumeric( + public async fetchNumeric( sql: string, params: QueryParameters = [], types: QueryParameterTypes = [], - ): Promise { - return (await this.executeQuery(sql, params, types)).fetchNumeric(); + ): Promise { + return (await this.executeQuery(sql, params, types)).fetchNumeric(); } - public async fetchOne( + public async fetchOne( sql: string, params: QueryParameters = [], types: QueryParameterTypes = [], - ): Promise { - return (await this.executeQuery(sql, params, types)).fetchOne(); + ): Promise { + return (await this.executeQuery(sql, params, types)).fetchOne(); } - public async fetchAllAssociative( + public async fetchAllAssociative( sql: string, params: QueryParameters = [], types: QueryParameterTypes = [], - ): Promise>> { - return (await this.executeQuery(sql, params, types)).fetchAllAssociative(); + ): Promise { + return (await this.executeQuery(sql, params, types)).fetchAllAssociative(); } - public async fetchAllNumeric( + public async fetchAllNumeric( sql: string, params: QueryParameters = [], types: QueryParameterTypes = [], - ): Promise { - return (await this.executeQuery(sql, params, types)).fetchAllNumeric(); + ): Promise { + return (await this.executeQuery(sql, params, types)).fetchAllNumeric(); } - public async fetchAllKeyValue( + public async fetchAllKeyValue( sql: string, params: QueryParameters = [], types: QueryParameterTypes = [], - ): Promise> { - return (await this.executeQuery(sql, params, types)).fetchAllKeyValue(); + ): Promise> { + return (await this.executeQuery(sql, params, types)).fetchAllKeyValue(); } - public async fetchAllAssociativeIndexed( + public async fetchAllAssociativeIndexed( sql: string, params: QueryParameters = [], types: QueryParameterTypes = [], - ): Promise>> { - return (await this.executeQuery(sql, params, types)).fetchAllAssociativeIndexed(); + ): Promise> { + return (await this.executeQuery(sql, params, types)).fetchAllAssociativeIndexed(); } - public async fetchFirstColumn( + public async fetchFirstColumn( sql: string, params: QueryParameters = [], types: QueryParameterTypes = [], - ): Promise { - return (await this.executeQuery(sql, params, types)).fetchFirstColumn(); + ): Promise { + return (await this.executeQuery(sql, params, types)).fetchFirstColumn(); } public async delete( table: string, - criteria: Record = {}, + criteria: DataMap = {}, types: QueryParameterTypes = [], ): Promise { const [columns, values, conditions] = this.getCriteriaCondition(criteria); @@ -264,8 +278,8 @@ export class Connection implements StatementExecutor { public async update( table: string, - data: Record, - criteria: Record = {}, + data: DataMap, + criteria: DataMap = {}, types: QueryParameterTypes = [], ): Promise { const columns: string[] = []; @@ -294,7 +308,7 @@ export class Connection implements StatementExecutor { public async insert( table: string, - data: Record, + data: DataMap, types: QueryParameterTypes = [], ): Promise { const columns = Object.keys(data); @@ -474,7 +488,7 @@ export class Connection implements StatementExecutor { return `DATAZEN_${level}`; } - private getCriteriaCondition(criteria: Record): [string[], unknown[], string[]] { + private getCriteriaCondition(criteria: DataMap): [string[], unknown[], string[]] { const columns: string[] = []; const values: unknown[] = []; const conditions: string[] = []; @@ -576,7 +590,7 @@ export class Connection implements StatementExecutor { types: QueryScalarParameterType[], ): CompiledQuery { const sqlParts: string[] = []; - const namedParameters: Record = {}; + const namedParameters: DataMap = {}; const namedTypes: Record = {}; let parameterIndex = 0; let bindCounter = 0; @@ -741,7 +755,7 @@ export class Connection implements StatementExecutor { } if (!Array.isArray(params) && !Array.isArray(types)) { - const normalizedParams: Record = { ...params }; + const normalizedParams: DataMap = { ...params }; const normalizedTypes: Record = { ...types }; for (const [name, value] of Object.entries(normalizedParams)) { diff --git a/src/query/expression/expression-builder.ts b/src/query/expression/expression-builder.ts index 16b4f6b..d2aeaeb 100644 --- a/src/query/expression/expression-builder.ts +++ b/src/query/expression/expression-builder.ts @@ -1,11 +1,6 @@ -import { AbstractPlatform } from "../../platforms/abstract-platform"; -import { MySQLPlatform } from "../../platforms/mysql-platform"; +import { Connection } from "../../connection"; import { CompositeExpression } from "../expression/composite-expression"; -interface ExpressionBuilderConnection { - getDatabasePlatform(): AbstractPlatform; -} - export class ExpressionBuilder { static readonly EQ = "="; static readonly NEQ = "<>"; @@ -14,21 +9,7 @@ export class ExpressionBuilder { static readonly GT = ">"; static readonly GTE = ">="; - private readonly platform: AbstractPlatform; - - constructor(connectionOrPlatform?: ExpressionBuilderConnection | AbstractPlatform) { - if (connectionOrPlatform === undefined) { - this.platform = new MySQLPlatform(); - return; - } - - if (connectionOrPlatform instanceof AbstractPlatform) { - this.platform = connectionOrPlatform; - return; - } - - this.platform = connectionOrPlatform.getDatabasePlatform(); - } + constructor(private readonly connection: Connection) {} /** * Creates a conjunction of the given expressions. @@ -178,7 +159,7 @@ export class ExpressionBuilder { * * The usage of this method is discouraged. Use prepared statements */ - literal(input: string): string { - return this.platform.quoteStringLiteral(input); + async literal(input: string): Promise { + return await this.connection.quote(input); } } diff --git a/src/query/query-builder.ts b/src/query/query-builder.ts index ae2583b..17e0fe0 100644 --- a/src/query/query-builder.ts +++ b/src/query/query-builder.ts @@ -19,6 +19,7 @@ import { Union } from "./union"; import { UnionQuery } from "./union-query"; import { UnionType } from "./union-type"; +type DataMap = Record; type ParamType = string | ParameterType | ArrayParameterType; export enum PlaceHolder { @@ -83,8 +84,8 @@ export class QueryBuilder { /** * Executes an SQL query (SELECT) and returns a Result. */ - public executeQuery(): Promise { - return this.connection.executeQuery(this.getSQL(), this.params, this.types); + public executeQuery(): Promise> { + return this.connection.executeQuery(this.getSQL(), this.params, this.types); } /** @@ -94,9 +95,7 @@ export class QueryBuilder { return this.connection.executeStatement(this.getSQL(), this.params, this.types); } - public async fetchAssociative< - T extends Record = Record, - >(): Promise { + public async fetchAssociative(): Promise { return (await this.executeQuery()).fetchAssociative(); } @@ -112,9 +111,7 @@ export class QueryBuilder { return (await this.executeQuery()).fetchAllNumeric(); } - public async fetchAllAssociative< - T extends Record = Record, - >(): Promise { + public async fetchAllAssociative(): Promise { return (await this.executeQuery()).fetchAllAssociative(); } @@ -122,9 +119,9 @@ export class QueryBuilder { return (await this.executeQuery()).fetchAllKeyValue(); } - public async fetchAllAssociativeIndexed< - T extends Record = Record, - >(): Promise> { + public async fetchAllAssociativeIndexed(): Promise< + Record + > { return (await this.executeQuery()).fetchAllAssociativeIndexed(); } @@ -391,7 +388,7 @@ export class QueryBuilder { */ public insertWith( table: string, - data: Record, + data: DataMap, placeHolder: PlaceHolder = PlaceHolder.POSITIONAL, ): this { if (!data || Object.keys(data).length === 0) { @@ -421,7 +418,7 @@ export class QueryBuilder { */ public updateWith( table: string, - data: Record, + data: DataMap, placeHolder: PlaceHolder = PlaceHolder.POSITIONAL, ): this { if (!data || Object.keys(data).length === 0) { @@ -749,7 +746,7 @@ export class QueryBuilder { * @link http://www.zetacomponents.org */ public createNamedParameter( - value: any, + value: unknown, type: ParamType = ParameterType.STRING, placeHolder: string | null = null, ): string { @@ -773,7 +770,7 @@ export class QueryBuilder { * statement , otherwise they get bound in the wrong order which can lead to serious * bugs in your code. */ - public createPositionalParameter(value: any, type: ParamType = ParameterType.STRING): string { + public createPositionalParameter(value: unknown, type: ParamType = ParameterType.STRING): string { this.setParameter(this.boundCounter, value, type); this.boundCounter++; @@ -991,7 +988,7 @@ export class QueryBuilder { return this.types as ParamType[]; } - private ensureNamedParams(): Record { + private ensureNamedParams(): DataMap { if (Array.isArray(this.params)) { this.params = {}; } diff --git a/src/result.ts b/src/result.ts index 9d5700d..150f13c 100644 --- a/src/result.ts +++ b/src/result.ts @@ -4,14 +4,14 @@ import { NoKeyValueException } from "./exception/index"; type AssociativeRow = Record; type NumericRow = unknown[]; -export class Result { - private rows: AssociativeRow[]; +export class Result { + private rows: TRow[]; private cursor = 0; private readonly explicitColumns: string[]; private readonly explicitRowCount?: number; constructor(result: DriverQueryResult) { - this.rows = [...result.rows]; + this.rows = [...result.rows] as TRow[]; this.explicitColumns = result.columns ?? []; this.explicitRowCount = result.rowCount; } @@ -26,14 +26,14 @@ export class Result { return columns.map((column) => row[column]) as T; } - public fetchAssociative(): T | false { + public fetchAssociative(): T | false { const row = this.rows[this.cursor]; if (row === undefined) { return false; } this.cursor += 1; - return { ...row } as T; + return { ...row } as unknown as T; } public fetchOne(): T | false { @@ -58,7 +58,7 @@ export class Result { return rows; } - public fetchAllAssociative(): T[] { + public fetchAllAssociative(): T[] { const rows: T[] = []; let row = this.fetchAssociative(); @@ -92,7 +92,7 @@ export class Result { string, T > { - const rows = this.fetchAllAssociative(); + const rows = this.fetchAllAssociative(); const indexed: Record = {}; for (const row of rows) { diff --git a/src/statement.ts b/src/statement.ts index 84d480c..b5bdfee 100644 --- a/src/statement.ts +++ b/src/statement.ts @@ -60,9 +60,11 @@ export class Statement { return this; } - public async executeQuery(): Promise { + public async executeQuery< + TRow extends Record = Record, + >(): Promise> { const [params, types] = this.getBoundParameters(); - return this.executor.executeQuery(this.sql, params, types); + return (await this.executor.executeQuery(this.sql, params, types)) as Result; } public async executeStatement(): Promise { From 6f1c6986535c7315fb54c4e6564307b1266ac03a Mon Sep 17 00:00:00 2001 From: bernard-ng Date: Sun, 22 Feb 2026 16:39:11 +0200 Subject: [PATCH 02/24] feat: schema support init --- CHANGELOG.md | 57 +++ docs/architecture.md | 35 +- docs/configuration.md | 17 +- docs/data-retrieval-and-manipulation.md | 2 +- docs/introduction.md | 2 +- docs/platforms.md | 15 +- docs/portability.md | 19 +- docs/query-builder.md | 4 +- docs/supporting-other-databases.md | 4 +- docs/types.md | 9 +- package.json | 63 ++- src/__tests__/package/subpath-exports.test.ts | 74 +++ src/__tests__/platforms/keywords.test.ts | 35 ++ .../platforms/platform-parity.test.ts | 5 +- src/__tests__/platforms/platforms.test.ts | 5 +- src/__tests__/schema/schema-assets.test.ts | 65 +++ .../schema/schema-comparator-editor.test.ts | 33 ++ .../schema/schema-exception-parity.test.ts | 456 ++++++++++++++++++ .../schema/schema-file-parity.test.ts | 73 +++ src/__tests__/schema/schema-manager.test.ts | 123 +++++ .../schema-name-introspection-parity.test.ts | 313 ++++++++++++ src/configuration.ts | 25 + src/connection.ts | 10 + src/driver/index.ts | 29 ++ src/index.ts | 97 +--- src/lock-mode.ts | 7 +- src/platforms/abstract-mysql-platform.ts | 12 + src/platforms/abstract-platform.ts | 22 + src/platforms/db2-platform.ts | 12 + src/platforms/index.ts | 14 + src/platforms/keywords/db2-keywords.ts | 402 +++++++++++++++ src/platforms/keywords/empty-keywords.ts | 7 + src/platforms/keywords/index.ts | 12 + src/platforms/keywords/keyword-list.ts | 22 + src/platforms/keywords/mariadb-keywords.ts | 255 ++++++++++ src/platforms/keywords/mariadb117-keywords.ts | 7 + src/platforms/keywords/mysql-keywords.ts | 243 ++++++++++ src/platforms/keywords/mysql80-keywords.ts | 7 + src/platforms/keywords/mysql84-keywords.ts | 7 + src/platforms/keywords/oracle-keywords.ts | 121 +++++ src/platforms/keywords/postgresql-keywords.ts | 107 ++++ src/platforms/keywords/sql-server-keywords.ts | 193 ++++++++ src/platforms/keywords/sqlite-keywords.ts | 129 +++++ src/platforms/oracle-platform.ts | 12 + src/platforms/sql-server-platform.ts | 22 +- src/query/index.ts | 17 + src/schema/abstract-asset.ts | 98 ++++ src/schema/abstract-named-object.ts | 8 + .../abstract-optionally-named-object.ts | 22 + src/schema/abstract-schema-manager.ts | 91 ++++ src/schema/collections/exception.ts | 1 + .../exception/object-already-exists.ts | 20 + .../exception/object-does-not-exist.ts | 20 + src/schema/collections/index.ts | 6 + src/schema/collections/object-set.ts | 8 + ...optionally-unqualified-named-object-set.ts | 64 +++ .../unqualified-named-object-set.ts | 55 +++ src/schema/column-diff.ts | 13 + src/schema/column-editor.ts | 101 ++++ src/schema/column.ts | 286 +++++++++++ src/schema/comparator-config.ts | 22 + src/schema/comparator.ts | 186 +++++++ src/schema/db2-schema-manager.ts | 11 + src/schema/default-expression.ts | 3 + src/schema/default-expression/current-date.ts | 7 + src/schema/default-expression/current-time.ts | 7 + .../default-expression/current-timestamp.ts | 7 + src/schema/default-expression/index.ts | 3 + src/schema/default-schema-manager-factory.ts | 12 + src/schema/exception/_util.ts | 42 ++ src/schema/exception/column-already-exists.ts | 14 + src/schema/exception/column-does-not-exist.ts | 14 + .../exception/foreign-key-does-not-exist.ts | 14 + src/schema/exception/incomparable-names.ts | 15 + src/schema/exception/index-already-exists.ts | 14 + src/schema/exception/index-does-not-exist.ts | 12 + src/schema/exception/index-name-invalid.ts | 12 + src/schema/exception/index.ts | 31 ++ .../exception/invalid-column-definition.ts | 19 + ...valid-foreign-key-constraint-definition.ts | 33 ++ src/schema/exception/invalid-identifier.ts | 12 + .../exception/invalid-index-definition.ts | 26 + src/schema/exception/invalid-name.ts | 12 + ...valid-primary-key-constraint-definition.ts | 14 + .../exception/invalid-sequence-definition.ts | 18 + src/schema/exception/invalid-state.ts | 74 +++ .../exception/invalid-table-definition.ts | 15 + .../exception/invalid-table-modification.ts | 161 +++++++ src/schema/exception/invalid-table-name.ts | 12 + .../invalid-unique-constraint-definition.ts | 15 + .../exception/invalid-view-definition.ts | 16 + .../exception/namespace-already-exists.ts | 12 + src/schema/exception/not-implemented.ts | 12 + .../exception/primary-key-already-exists.ts | 12 + .../exception/sequence-already-exists.ts | 12 + .../exception/sequence-does-not-exist.ts | 12 + src/schema/exception/table-already-exists.ts | 12 + src/schema/exception/table-does-not-exist.ts | 12 + .../unique-constraint-does-not-exist.ts | 14 + src/schema/exception/unknown-column-option.ts | 12 + src/schema/exception/unsupported-name.ts | 18 + src/schema/exception/unsupported-schema.ts | 20 + src/schema/foreign-key-constraint-editor.ts | 124 +++++ src/schema/foreign-key-constraint.ts | 111 +++++ .../foreign-key-constraint/deferrability.ts | 9 + src/schema/foreign-key-constraint/index.ts | 3 + .../foreign-key-constraint/match-type.ts | 9 + .../referential-action.ts | 11 + src/schema/identifier.ts | 14 + src/schema/index-editor.ts | 126 +++++ src/schema/index.ts | 148 ++++++ src/schema/index/index-type.ts | 6 + src/schema/index/index.ts | 2 + src/schema/index/indexed-column.ts | 28 ++ src/schema/introspection/index.ts | 2 + .../introspecting-schema-provider.ts | 11 + ...ey-constraint-column-metadata-processor.ts | 49 ++ .../index-column-metadata-processor.ts | 21 + .../introspection/metadata-processor/index.ts | 5 + ...ey-constraint-column-metadata-processor.ts | 14 + .../sequence-metadata-processor.ts | 13 + .../view-metadata-processor.ts | 11 + src/schema/metadata/database-metadata-row.ts | 7 + ...eign-key-constraint-column-metadata-row.ts | 82 ++++ .../metadata/index-column-metadata-row.ts | 46 ++ src/schema/metadata/index.ts | 10 + src/schema/metadata/metadata-provider.ts | 39 ++ .../primary-key-constraint-column-row.ts | 29 ++ src/schema/metadata/schema-metadata-row.ts | 7 + src/schema/metadata/sequence-metadata-row.ts | 29 ++ .../metadata/table-column-metadata-row.ts | 21 + src/schema/metadata/table-metadata-row.ts | 19 + src/schema/metadata/view-metadata-row.ts | 19 + src/schema/module.ts | 51 ++ src/schema/mysql-schema-manager.ts | 11 + src/schema/name.ts | 6 + src/schema/name/generic-name.ts | 23 + src/schema/name/identifier.ts | 63 +++ src/schema/name/index.ts | 8 + src/schema/name/optionally-qualified-name.ts | 80 +++ src/schema/name/parser.ts | 5 + src/schema/name/parser/exception.ts | 1 + .../name/parser/exception/expected-dot.ts | 10 + .../exception/expected-next-identifier.ts | 10 + src/schema/name/parser/exception/index.ts | 5 + .../name/parser/exception/invalid-name.ts | 16 + .../exception/unable-to-parse-identifier.ts | 10 + src/schema/name/parser/generic-name-parser.ts | 136 ++++++ src/schema/name/parser/index.ts | 5 + .../optionally-qualified-name-parser.ts | 31 ++ .../name/parser/unqualified-name-parser.ts | 23 + src/schema/name/parsers.ts | 28 ++ src/schema/name/unqualified-name.ts | 36 ++ .../name/unquoted-identifier-folding.ts | 16 + src/schema/named-object.ts | 3 + src/schema/optionally-named-object.ts | 3 + src/schema/oracle-schema-manager.ts | 11 + src/schema/postgre-sql-schema-manager.ts | 11 + src/schema/primary-key-constraint-editor.ts | 32 ++ src/schema/primary-key-constraint.ts | 40 ++ src/schema/schema-config.ts | 32 ++ src/schema/schema-diff.ts | 49 ++ src/schema/schema-exception.ts | 1 + src/schema/schema-manager-factory.ts | 9 + src/schema/schema-provider.ts | 5 + src/schema/schema.ts | 138 ++++++ src/schema/sequence-editor.ts | 50 ++ src/schema/sequence.ts | 37 ++ src/schema/sql-server-schema-manager.ts | 11 + src/schema/sqlite-schema-manager.ts | 11 + src/schema/table-configuration.ts | 10 + src/schema/table-diff.ts | 51 ++ src/schema/table-editor.ts | 101 ++++ src/schema/table.ts | 272 +++++++++++ src/schema/unique-constraint-editor.ts | 32 ++ src/schema/unique-constraint.ts | 90 ++++ src/schema/view-editor.ts | 34 ++ src/schema/view.ts | 23 + src/sql/index.ts | 8 + src/tools/index.ts | 2 + tsup.config.ts | 14 +- 181 files changed, 7511 insertions(+), 162 deletions(-) create mode 100644 src/__tests__/package/subpath-exports.test.ts create mode 100644 src/__tests__/platforms/keywords.test.ts create mode 100644 src/__tests__/schema/schema-assets.test.ts create mode 100644 src/__tests__/schema/schema-comparator-editor.test.ts create mode 100644 src/__tests__/schema/schema-exception-parity.test.ts create mode 100644 src/__tests__/schema/schema-file-parity.test.ts create mode 100644 src/__tests__/schema/schema-manager.test.ts create mode 100644 src/__tests__/schema/schema-name-introspection-parity.test.ts create mode 100644 src/driver/index.ts create mode 100644 src/platforms/keywords/db2-keywords.ts create mode 100644 src/platforms/keywords/empty-keywords.ts create mode 100644 src/platforms/keywords/index.ts create mode 100644 src/platforms/keywords/keyword-list.ts create mode 100644 src/platforms/keywords/mariadb-keywords.ts create mode 100644 src/platforms/keywords/mariadb117-keywords.ts create mode 100644 src/platforms/keywords/mysql-keywords.ts create mode 100644 src/platforms/keywords/mysql80-keywords.ts create mode 100644 src/platforms/keywords/mysql84-keywords.ts create mode 100644 src/platforms/keywords/oracle-keywords.ts create mode 100644 src/platforms/keywords/postgresql-keywords.ts create mode 100644 src/platforms/keywords/sql-server-keywords.ts create mode 100644 src/platforms/keywords/sqlite-keywords.ts create mode 100644 src/query/index.ts create mode 100644 src/schema/abstract-asset.ts create mode 100644 src/schema/abstract-named-object.ts create mode 100644 src/schema/abstract-optionally-named-object.ts create mode 100644 src/schema/abstract-schema-manager.ts create mode 100644 src/schema/collections/exception.ts create mode 100644 src/schema/collections/exception/object-already-exists.ts create mode 100644 src/schema/collections/exception/object-does-not-exist.ts create mode 100644 src/schema/collections/index.ts create mode 100644 src/schema/collections/object-set.ts create mode 100644 src/schema/collections/optionally-unqualified-named-object-set.ts create mode 100644 src/schema/collections/unqualified-named-object-set.ts create mode 100644 src/schema/column-diff.ts create mode 100644 src/schema/column-editor.ts create mode 100644 src/schema/column.ts create mode 100644 src/schema/comparator-config.ts create mode 100644 src/schema/comparator.ts create mode 100644 src/schema/db2-schema-manager.ts create mode 100644 src/schema/default-expression.ts create mode 100644 src/schema/default-expression/current-date.ts create mode 100644 src/schema/default-expression/current-time.ts create mode 100644 src/schema/default-expression/current-timestamp.ts create mode 100644 src/schema/default-expression/index.ts create mode 100644 src/schema/default-schema-manager-factory.ts create mode 100644 src/schema/exception/_util.ts create mode 100644 src/schema/exception/column-already-exists.ts create mode 100644 src/schema/exception/column-does-not-exist.ts create mode 100644 src/schema/exception/foreign-key-does-not-exist.ts create mode 100644 src/schema/exception/incomparable-names.ts create mode 100644 src/schema/exception/index-already-exists.ts create mode 100644 src/schema/exception/index-does-not-exist.ts create mode 100644 src/schema/exception/index-name-invalid.ts create mode 100644 src/schema/exception/index.ts create mode 100644 src/schema/exception/invalid-column-definition.ts create mode 100644 src/schema/exception/invalid-foreign-key-constraint-definition.ts create mode 100644 src/schema/exception/invalid-identifier.ts create mode 100644 src/schema/exception/invalid-index-definition.ts create mode 100644 src/schema/exception/invalid-name.ts create mode 100644 src/schema/exception/invalid-primary-key-constraint-definition.ts create mode 100644 src/schema/exception/invalid-sequence-definition.ts create mode 100644 src/schema/exception/invalid-state.ts create mode 100644 src/schema/exception/invalid-table-definition.ts create mode 100644 src/schema/exception/invalid-table-modification.ts create mode 100644 src/schema/exception/invalid-table-name.ts create mode 100644 src/schema/exception/invalid-unique-constraint-definition.ts create mode 100644 src/schema/exception/invalid-view-definition.ts create mode 100644 src/schema/exception/namespace-already-exists.ts create mode 100644 src/schema/exception/not-implemented.ts create mode 100644 src/schema/exception/primary-key-already-exists.ts create mode 100644 src/schema/exception/sequence-already-exists.ts create mode 100644 src/schema/exception/sequence-does-not-exist.ts create mode 100644 src/schema/exception/table-already-exists.ts create mode 100644 src/schema/exception/table-does-not-exist.ts create mode 100644 src/schema/exception/unique-constraint-does-not-exist.ts create mode 100644 src/schema/exception/unknown-column-option.ts create mode 100644 src/schema/exception/unsupported-name.ts create mode 100644 src/schema/exception/unsupported-schema.ts create mode 100644 src/schema/foreign-key-constraint-editor.ts create mode 100644 src/schema/foreign-key-constraint.ts create mode 100644 src/schema/foreign-key-constraint/deferrability.ts create mode 100644 src/schema/foreign-key-constraint/index.ts create mode 100644 src/schema/foreign-key-constraint/match-type.ts create mode 100644 src/schema/foreign-key-constraint/referential-action.ts create mode 100644 src/schema/identifier.ts create mode 100644 src/schema/index-editor.ts create mode 100644 src/schema/index.ts create mode 100644 src/schema/index/index-type.ts create mode 100644 src/schema/index/index.ts create mode 100644 src/schema/index/indexed-column.ts create mode 100644 src/schema/introspection/index.ts create mode 100644 src/schema/introspection/introspecting-schema-provider.ts create mode 100644 src/schema/introspection/metadata-processor/foreign-key-constraint-column-metadata-processor.ts create mode 100644 src/schema/introspection/metadata-processor/index-column-metadata-processor.ts create mode 100644 src/schema/introspection/metadata-processor/index.ts create mode 100644 src/schema/introspection/metadata-processor/primary-key-constraint-column-metadata-processor.ts create mode 100644 src/schema/introspection/metadata-processor/sequence-metadata-processor.ts create mode 100644 src/schema/introspection/metadata-processor/view-metadata-processor.ts create mode 100644 src/schema/metadata/database-metadata-row.ts create mode 100644 src/schema/metadata/foreign-key-constraint-column-metadata-row.ts create mode 100644 src/schema/metadata/index-column-metadata-row.ts create mode 100644 src/schema/metadata/index.ts create mode 100644 src/schema/metadata/metadata-provider.ts create mode 100644 src/schema/metadata/primary-key-constraint-column-row.ts create mode 100644 src/schema/metadata/schema-metadata-row.ts create mode 100644 src/schema/metadata/sequence-metadata-row.ts create mode 100644 src/schema/metadata/table-column-metadata-row.ts create mode 100644 src/schema/metadata/table-metadata-row.ts create mode 100644 src/schema/metadata/view-metadata-row.ts create mode 100644 src/schema/module.ts create mode 100644 src/schema/mysql-schema-manager.ts create mode 100644 src/schema/name.ts create mode 100644 src/schema/name/generic-name.ts create mode 100644 src/schema/name/identifier.ts create mode 100644 src/schema/name/index.ts create mode 100644 src/schema/name/optionally-qualified-name.ts create mode 100644 src/schema/name/parser.ts create mode 100644 src/schema/name/parser/exception.ts create mode 100644 src/schema/name/parser/exception/expected-dot.ts create mode 100644 src/schema/name/parser/exception/expected-next-identifier.ts create mode 100644 src/schema/name/parser/exception/index.ts create mode 100644 src/schema/name/parser/exception/invalid-name.ts create mode 100644 src/schema/name/parser/exception/unable-to-parse-identifier.ts create mode 100644 src/schema/name/parser/generic-name-parser.ts create mode 100644 src/schema/name/parser/index.ts create mode 100644 src/schema/name/parser/optionally-qualified-name-parser.ts create mode 100644 src/schema/name/parser/unqualified-name-parser.ts create mode 100644 src/schema/name/parsers.ts create mode 100644 src/schema/name/unqualified-name.ts create mode 100644 src/schema/name/unquoted-identifier-folding.ts create mode 100644 src/schema/named-object.ts create mode 100644 src/schema/optionally-named-object.ts create mode 100644 src/schema/oracle-schema-manager.ts create mode 100644 src/schema/postgre-sql-schema-manager.ts create mode 100644 src/schema/primary-key-constraint-editor.ts create mode 100644 src/schema/primary-key-constraint.ts create mode 100644 src/schema/schema-config.ts create mode 100644 src/schema/schema-diff.ts create mode 100644 src/schema/schema-exception.ts create mode 100644 src/schema/schema-manager-factory.ts create mode 100644 src/schema/schema-provider.ts create mode 100644 src/schema/schema.ts create mode 100644 src/schema/sequence-editor.ts create mode 100644 src/schema/sequence.ts create mode 100644 src/schema/sql-server-schema-manager.ts create mode 100644 src/schema/sqlite-schema-manager.ts create mode 100644 src/schema/table-configuration.ts create mode 100644 src/schema/table-diff.ts create mode 100644 src/schema/table-editor.ts create mode 100644 src/schema/table.ts create mode 100644 src/schema/unique-constraint-editor.ts create mode 100644 src/schema/unique-constraint.ts create mode 100644 src/schema/view-editor.ts create mode 100644 src/schema/view.ts create mode 100644 src/sql/index.ts create mode 100644 src/tools/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be07b82..41078a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # @devscast/datazen +# Unreleased +- Added package subpath namespace exports so consumers can import grouped APIs from: + - `@devscast/datazen/driver` + - `@devscast/datazen/exception` + - `@devscast/datazen/logging` + - `@devscast/datazen/platforms` + - `@devscast/datazen/portability` + - `@devscast/datazen/query` + - `@devscast/datazen/schema` + - `@devscast/datazen/sql` + - `@devscast/datazen/tools` + - `@devscast/datazen/types` +- Added namespace barrel entry points for `driver`, `query`, `sql`, and `tools`. +- Updated build configuration to emit multi-entry bundles/types for subpath exports. +- Added coverage test for namespace barrels and `package.json` subpath export declarations. +- Breaking: reduced root `@devscast/datazen` exports to modules backed by files directly under `src/`; grouped APIs now require subpath imports (for example `@devscast/datazen/query`, `@devscast/datazen/types`, `@devscast/datazen/platforms`). + +# 1.0.2 - Schema Foundation Parity +- Ported a Doctrine-inspired schema foundation under `src/schema/*`: + - schema assets: `AbstractAsset`, `Identifier`, `Column`, `Index`, `ForeignKeyConstraint`, `Table`, `Sequence`, `View`, `Schema`, `SchemaConfig` + - schema management: `AbstractSchemaManager`, `MySQLSchemaManager`, `SQLServerSchemaManager`, `OracleSchemaManager`, `DB2SchemaManager` + - schema-manager factories: `SchemaManagerFactory`, `DefaultSchemaManagerFactory` +- Added best-effort file-by-file Schema namespace parity with Doctrine reference layout: + - created missing files/sub-namespaces under `src/schema/*` (`collections`, `default-expression`, `exception`, `foreign-key-constraint`, `index`, `introspection`, `metadata`, `name`) + - added `src/schema/module.ts` as a stable schema-module export surface + - exposed schema module namespace from top-level API as `SchemaModule` +- Added parity scaffolding and practical implementations for schema diffing/building APIs: + - `Comparator`, `ComparatorConfig`, `ColumnDiff`, `TableDiff`, `SchemaDiff` + - editor builders (`ColumnEditor`, `TableEditor`, `IndexEditor`, `ForeignKeyConstraintEditor`, `PrimaryKeyConstraintEditor`, `UniqueConstraintEditor`, `SequenceEditor`, `ViewEditor`) + - constraint and table-configuration objects (`PrimaryKeyConstraint`, `UniqueConstraint`, `TableConfiguration`) +- Added `Connection.createSchemaManager()` and `Configuration` support for: + - `schemaManagerFactory` override injection + - `schemaAssetsFilter` hook (default pass-through) +- Ported Doctrine keyword-list support in `src/platforms/keywords/*`, including MySQL/MariaDB versioned lists and PostgreSQL/SQLite keyword classes. +- Extended platform integration to expose: + - `getReservedKeywordsList()` + - dialect-specific schema manager creation via `createSchemaManager(connection)` +- Added coverage tests: + - `src/__tests__/platforms/keywords.test.ts` + - `src/__tests__/schema/schema-assets.test.ts` + - `src/__tests__/schema/schema-manager.test.ts` + - `src/__tests__/schema/schema-comparator-editor.test.ts` + - `src/__tests__/schema/schema-file-parity.test.ts` +- Expanded Doctrine parity coverage for schema exceptions in `src/__tests__/schema/schema-exception-parity.test.ts`: + - added message assertions for previously untested schema exception factories (`IncomparableNames`, `IndexNameInvalid`, `InvalidIdentifier`, `InvalidName`, `InvalidTableName`, `NotImplemented`, `UniqueConstraintDoesNotExist`, `UnsupportedName`) + - added full `InvalidState` factory message coverage and additional `InvalidTableModification` factory/cause assertions +- Ported additional Doctrine schema internals beyond scaffolding: + - `src/schema/index/indexed-column.ts` now validates and exposes indexed-column metadata (name + optional positive length) + - `src/schema/name/*` and `src/schema/name/parser/*` now implement Doctrine-style name value objects, identifier folding, and SQL-like parser behavior + - `src/schema/metadata/*` row classes now carry Doctrine-style metadata DTO state/getters + - `src/schema/introspection/metadata-processor/*` now combine metadata rows into schema editors/objects (indexes, PK/FK constraints, sequences, views) + - added editor compatibility helpers used by metadata processors (`IndexEditor`, `ForeignKeyConstraintEditor`, `PrimaryKeyConstraintEditor`, `SequenceEditor`, `ViewEditor`) +- Added schema parity behavior tests in `src/__tests__/schema/schema-name-introspection-parity.test.ts` covering: + - name objects/parsers (`GenericName`, `Identifier`, `UnqualifiedName`, `OptionallyQualifiedName`, parser registry) + - `IndexedColumn` + - metadata rows and metadata processors + # 1.0.1 - Convenience DML Parity - Added phase 1 TypeScript-friendly typed row propagation: - `Result` now carries a default row shape for associative fetch methods. diff --git a/docs/architecture.md b/docs/architecture.md index b034903..c8929d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,9 +20,7 @@ Wrapper layer The main application-facing components are: -- `Connection` (`src/connection.ts`) -- `Statement` (`src/statement.ts`) -- `Result` (`src/result.ts`) +- `Connection`, `Statement`, `Result` (root import: `@devscast/datazen`) `Connection` orchestrates parameter expansion/compilation, transaction control, type conversion, exception conversion, and delegates execution to the active @@ -33,13 +31,11 @@ Driver layer The driver abstraction is centered around: -- `Driver` (`src/driver.ts`) -- `DriverConnection` (`src/driver.ts`) +- `Driver`, `DriverConnection` (`@devscast/datazen/driver`) Concrete adapters: -- MySQL2: `src/driver/mysql2/*` -- MSSQL: `src/driver/mssql/*` +- MySQL2 and MSSQL adapters are exposed through `@devscast/datazen/driver` Doctrine has separate low-level `Driver\Statement` and `Driver\Result` interfaces. In this Node port, the low-level contract is simplified to @@ -50,7 +46,7 @@ maps better to Node driver APIs. Driver Manager -------------- -`DriverManager` (`src/driver-manager.ts`) is responsible for: +`DriverManager` (root import: `@devscast/datazen`) is responsible for: 1. Resolving a driver from params (`driver`, `driverClass`, `driverInstance`) 2. Applying configured middleware in order @@ -61,18 +57,18 @@ Middlewares Middleware decorates the driver stack through `DriverMiddleware`: -- Logging middleware: `src/logging/*` -- Portability middleware: `src/portability/*` +- Logging middleware: `@devscast/datazen/logging` +- Portability middleware: `@devscast/datazen/portability` -The middleware pipeline is configured via `Configuration` (`src/configuration.ts`). +The middleware pipeline is configured via `Configuration` (root import: `@devscast/datazen`). Parameter Expansion and SQL Parsing ----------------------------------- Array/list parameter expansion follows Doctrine’s model: -- `ExpandArrayParameters` (`src/expand-array-parameters.ts`) -- SQL parser + visitor (`src/sql/parser.ts`, `src/sql/parser/visitor.ts`) +- `ExpandArrayParameters` (root import: `@devscast/datazen`) +- SQL parser + visitor (`@devscast/datazen/sql`) `Connection` uses this flow to transform SQL and parameters before execution. For named-binding drivers (MSSQL), positional placeholders are rewritten into @@ -82,7 +78,7 @@ Platforms --------- Platforms provide dialect capabilities and feature flags through -`AbstractPlatform` (`src/platforms/abstract-platform.ts`) and concrete +`AbstractPlatform` (from `@devscast/datazen/platforms`) and concrete implementations (`mysql`, `sql-server`, `oracle`, `db2`). They are used for SQL dialect behaviors, quoting, date/time and expression @@ -91,7 +87,7 @@ helpers, and type mapping metadata. Types ----- -The types subsystem (`src/types/*`) provides runtime conversion between Node +The types subsystem (`@devscast/datazen/types`) provides runtime conversion between Node values and database representations, inspired by Doctrine DBAL Types. `Connection` integrates this layer when binding and reading typed values. @@ -99,25 +95,24 @@ values and database representations, inspired by Doctrine DBAL Types. Query Layer ----------- -The query API (`src/query/*`) includes a Doctrine-inspired QueryBuilder and +The query API (`@devscast/datazen/query`) includes a Doctrine-inspired QueryBuilder and related expression/query objects. Query generation and execution remain separated: generated SQL is executed through `Connection`. Exceptions ---------- -Exceptions are normalized in `src/exception/*`. Driver-specific errors are +Exceptions are normalized in `@devscast/datazen/exception`. Driver-specific errors are translated through per-driver exception converters: -- `src/driver/api/mysql/exception-converter.ts` -- `src/driver/api/sqlsrv/exception-converter.ts` +- `MySQLExceptionConverter` / `SQLSrvExceptionConverter` from `@devscast/datazen/driver` Tools ----- Implemented tooling currently includes: -- `DsnParser` (`src/tools/dsn-parser.ts`) +- `DsnParser` (`@devscast/datazen/tools`) Not Implemented --------------- diff --git a/docs/configuration.md b/docs/configuration.md index 5da7dea..32aa87c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,7 +32,8 @@ You can parse a DSN first, then pass the result to `DriverManager`. ```ts import mysql from "mysql2/promise"; -import { DriverManager, DsnParser } from "@devscast/datazen"; +import { DriverManager } from "@devscast/datazen"; +import { DsnParser } from "@devscast/datazen/tools"; const parser = new DsnParser(); const params = parser.parse("mysql2://user:secret@localhost/mydb?charset=utf8mb4"); @@ -130,7 +131,7 @@ Connecting using a URL (DSN) Examples: ```ts -import { DsnParser } from "@devscast/datazen"; +import { DsnParser } from "@devscast/datazen/tools"; const parser = new DsnParser({ custom: CustomDriver, // driverClass mapping @@ -153,10 +154,12 @@ import { ColumnCase, Configuration, DriverManager, - LoggingMiddleware, - PortabilityConnection, - PortabilityMiddleware, } from "@devscast/datazen"; +import { Middleware as LoggingMiddleware } from "@devscast/datazen/logging"; +import { + Connection as PortabilityConnection, + Middleware as PortabilityMiddleware, +} from "@devscast/datazen/portability"; const configuration = new Configuration() .addMiddleware(new LoggingMiddleware()) @@ -172,8 +175,8 @@ const conn = DriverManager.getConnection({ driver: "mssql", pool }, configuratio Supported built-in middlewares: -- Logging (`src/logging/*`) -- Portability (`src/portability/*`) +- Logging (`@devscast/datazen/logging`) +- Portability (`@devscast/datazen/portability`) Auto-commit Default ------------------- diff --git a/docs/data-retrieval-and-manipulation.md b/docs/data-retrieval-and-manipulation.md index aa40c64..00395ee 100644 --- a/docs/data-retrieval-and-manipulation.md +++ b/docs/data-retrieval-and-manipulation.md @@ -114,7 +114,7 @@ Binding Types You can bind: - `ParameterType` values (scalar DB binding types) -- DataZen type names / type instances (`src/types/*`) for value conversion +- DataZen type names / type instances (`@devscast/datazen/types`) for value conversion - `ArrayParameterType` values for list expansion Type conversion for scalar values is applied by `Connection` before execution. diff --git a/docs/introduction.md b/docs/introduction.md index 307c383..56d1a23 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -23,7 +23,7 @@ Platform abstractions currently shipped: - Oracle (platform-only) - Db2 (platform-only) -For runtime caveats and scope notes, see `docs/known-vendor-issues.md`. +For runtime caveats and scope notes, see [Known Vendor Issues](./known-vendor-issues.md). DBAL-first, ORM-independent --------------------------- diff --git a/docs/platforms.md b/docs/platforms.md index e82f948..a1c4402 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -74,7 +74,8 @@ Customizing the Platform Option 1: pass a custom platform directly in connection params. ```ts -import { DriverManager, MySQLPlatform } from "@devscast/datazen"; +import { DriverManager } from "@devscast/datazen"; +import { MySQLPlatform } from "@devscast/datazen/platforms"; class CustomMySQLPlatform extends MySQLPlatform { // override methods as needed @@ -91,14 +92,16 @@ Option 2: override platform through driver middleware. ```ts import { + Configuration, + DriverManager, +} from "@devscast/datazen"; +import { + ParameterBindingStyle, type Driver, type DriverConnection, type DriverMiddleware, - ParameterBindingStyle, - DriverManager, - Configuration, - SQLServerPlatform, -} from "@devscast/datazen"; +} from "@devscast/datazen/driver"; +import { SQLServerPlatform } from "@devscast/datazen/platforms"; class CustomSQLServerPlatform extends SQLServerPlatform {} diff --git a/docs/portability.md b/docs/portability.md index 30fe492..a9aaefc 100644 --- a/docs/portability.md +++ b/docs/portability.md @@ -16,12 +16,7 @@ The portability middleware currently targets result normalization concerns: - Right-trim of string values - Column name case normalization (lower/upper) -Implemented in: - -- `src/portability/middleware.ts` -- `src/portability/driver.ts` -- `src/portability/connection.ts` -- `src/portability/converter.ts` +Exposed via the `@devscast/datazen/portability` namespace (`Middleware`, `Driver`, `Connection`, `Converter`). Connection Wrapper ------------------ @@ -33,8 +28,8 @@ import { ColumnCase, Configuration, DriverManager, - Portability, } from "@devscast/datazen"; +import * as Portability from "@devscast/datazen/portability"; const configuration = new Configuration().addMiddleware( new Portability.Middleware( @@ -66,7 +61,7 @@ Enable only the flags you need. Platform Layer -------------- -SQL portability is handled separately by platform classes (`src/platforms/*`): +SQL portability is handled separately by platform classes (`@devscast/datazen/platforms`): - SQL function and expression generation - limit/offset adaptation @@ -79,7 +74,7 @@ matters. Platform Optimizations ---------------------- -`src/portability/optimize-flags.ts` applies vendor-specific optimization masks. +`OptimizeFlags` from `@devscast/datazen/portability` applies vendor-specific optimization masks. Example: Oracle already treats empty strings specially, so the `EMPTY_TO_NULL` portability flag is masked out for Oracle. @@ -92,6 +87,6 @@ In this port, schema/keyword-list modules are not implemented yet. Related Modules --------------- -- Types portability/conversion: `src/types/*` -- Parameter style and list expansion portability: `src/expand-array-parameters.ts` -- SQL parsing support: `src/sql/parser.ts` +- Types portability/conversion: `@devscast/datazen/types` +- Parameter style and list expansion portability: `ExpandArrayParameters` from `@devscast/datazen` +- SQL parsing support: `@devscast/datazen/sql` diff --git a/docs/query-builder.md b/docs/query-builder.md index a7be328..aa1d218 100644 --- a/docs/query-builder.md +++ b/docs/query-builder.md @@ -1,7 +1,7 @@ SQL Query Builder ================= -DataZen provides a Doctrine-inspired SQL Query Builder in `src/query/*`. +DataZen provides a Doctrine-inspired SQL Query Builder in `@devscast/datazen/query`. It builds SQL incrementally and executes through the `Connection` it belongs to. ```ts @@ -180,7 +180,7 @@ UNION ----- ```ts -import { UnionType } from "@devscast/datazen"; +import { UnionType } from "@devscast/datazen/query"; qb .union("SELECT 1 AS field") diff --git a/docs/supporting-other-databases.md b/docs/supporting-other-databases.md index 85fe4de..5b5d2ed 100644 --- a/docs/supporting-other-databases.md +++ b/docs/supporting-other-databases.md @@ -53,7 +53,7 @@ Implementation paths -------------------- Path A: New driver, existing platform -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +------------------------------------- 1. Add a new folder under `src/driver//`. 2. Implement: @@ -67,7 +67,7 @@ Path A: New driver, existing platform - `DRIVER_MAP` entry in `src/driver-manager.ts` Path B: New vendor/platform -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +--------------------------- 1. Create `src/platforms/-platform.ts` extending `AbstractPlatform`. 2. Implement vendor SQL behavior: diff --git a/docs/types.md b/docs/types.md index 1659811..35ad38a 100644 --- a/docs/types.md +++ b/docs/types.md @@ -17,7 +17,7 @@ Type Registry and Flyweights Types are resolved through `Type` static APIs: ```ts -import { Type, Types } from "@devscast/datazen"; +import { Type, Types } from "@devscast/datazen/types"; const integerType = Type.getType(Types.INTEGER); ``` @@ -38,7 +38,7 @@ The registry enforces invariants: Built-in Types -------------- -Built-ins are registered from `src/types/index.ts` and available via `Types` constants. +Built-ins are registered from the `@devscast/datazen/types` namespace and available via `Types` constants. Numeric: @@ -147,7 +147,8 @@ Custom Types Create a custom type by extending `Type` and registering it. ```ts -import { AbstractPlatform, Type } from "@devscast/datazen"; +import { AbstractPlatform } from "@devscast/datazen/platforms"; +import { Type } from "@devscast/datazen/types"; class MoneyType extends Type { public getSQLDeclaration( @@ -187,7 +188,7 @@ platform.registerDatazenTypeMapping("mymoney", "money"); Error Model ----------- -Type and conversion failures throw typed exceptions under `src/types/exception/*`, +Type and conversion failures throw typed exceptions from `@devscast/datazen/types`, including: - `UnknownColumnType` diff --git a/package.json b/package.json index de16438..789c5e1 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,68 @@ "version": "1.0.1", "type": "module", "source": "./src/index.ts", - "main": "./dist/index.js", + "main": "./dist/index.cjs", "types": "./dist/index.d.ts", - "module": "./dist/index.mjs", + "module": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + }, + "./driver": { + "types": "./dist/driver.d.ts", + "import": "./dist/driver.js", + "require": "./dist/driver.cjs" + }, + "./exception": { + "types": "./dist/exception.d.ts", + "import": "./dist/exception.js", + "require": "./dist/exception.cjs" + }, + "./logging": { + "types": "./dist/logging.d.ts", + "import": "./dist/logging.js", + "require": "./dist/logging.cjs" + }, + "./platforms": { + "types": "./dist/platforms.d.ts", + "import": "./dist/platforms.js", + "require": "./dist/platforms.cjs" + }, + "./portability": { + "types": "./dist/portability.d.ts", + "import": "./dist/portability.js", + "require": "./dist/portability.cjs" + }, + "./query": { + "types": "./dist/query.d.ts", + "import": "./dist/query.js", + "require": "./dist/query.cjs" + }, + "./schema": { + "types": "./dist/schema.d.ts", + "import": "./dist/schema.js", + "require": "./dist/schema.cjs" + }, + "./sql": { + "types": "./dist/sql.d.ts", + "import": "./dist/sql.js", + "require": "./dist/sql.cjs" + }, + "./tools": { + "types": "./dist/tools.d.ts", + "import": "./dist/tools.js", + "require": "./dist/tools.cjs" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js", + "require": "./dist/types.cjs" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "README.md" diff --git a/src/__tests__/package/subpath-exports.test.ts b/src/__tests__/package/subpath-exports.test.ts new file mode 100644 index 0000000..ebd2ea8 --- /dev/null +++ b/src/__tests__/package/subpath-exports.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { ParameterBindingStyle } from "../../driver/index"; +import { ConnectionException } from "../../exception/index"; +import * as Root from "../../index"; +import { ConsoleLogger } from "../../logging/index"; +import { AbstractPlatform } from "../../platforms/index"; +import { Converter } from "../../portability/index"; +import { QueryBuilder } from "../../query/index"; +import { Schema } from "../../schema/module"; +import { Parser } from "../../sql/index"; +import { DsnParser } from "../../tools/index"; +import { Type } from "../../types/index"; + +describe("package subpath namespaces", () => { + it("keeps root exports limited to top-level src modules", () => { + expect(Root.Connection).toBeDefined(); + expect(Root.DriverManager).toBeDefined(); + expect(Root.Query).toBeDefined(); + expect(Root.ParameterType).toBeDefined(); + + expect("DsnParser" in Root).toBe(false); + expect("QueryBuilder" in Root).toBe(false); + expect("AbstractPlatform" in Root).toBe(false); + expect("Type" in Root).toBe(false); + expect("Logging" in Root).toBe(false); + expect("Portability" in Root).toBe(false); + expect("SchemaModule" in Root).toBe(false); + }); + + it("exposes top-level namespace barrels", () => { + expect(ParameterBindingStyle.NAMED).toBe("named"); + expect(ConnectionException).toBeDefined(); + expect(ConsoleLogger).toBeDefined(); + expect(AbstractPlatform).toBeDefined(); + expect(Converter).toBeDefined(); + expect(QueryBuilder).toBeDefined(); + expect(Schema).toBeDefined(); + expect(Parser).toBeDefined(); + expect(DsnParser).toBeDefined(); + expect(Type).toBeDefined(); + }); + + it("declares package subpath exports for namespaces", () => { + const raw = readFileSync(new URL("../../../package.json", import.meta.url), "utf8"); + const pkg = JSON.parse(raw) as { + exports?: Record>; + }; + + expect(pkg.exports).toBeDefined(); + expect(pkg.exports?.["."]).toMatchObject({ + import: "./dist/index.js", + require: "./dist/index.cjs", + types: "./dist/index.d.ts", + }); + + for (const key of [ + "./driver", + "./exception", + "./logging", + "./platforms", + "./portability", + "./query", + "./schema", + "./sql", + "./tools", + "./types", + ]) { + expect(pkg.exports?.[key]).toBeDefined(); + } + }); +}); diff --git a/src/__tests__/platforms/keywords.test.ts b/src/__tests__/platforms/keywords.test.ts new file mode 100644 index 0000000..2cbdc80 --- /dev/null +++ b/src/__tests__/platforms/keywords.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { MySQLPlatform } from "../../platforms/mysql-platform"; +import { OraclePlatform } from "../../platforms/oracle-platform"; +import { SQLServerPlatform } from "../../platforms/sql-server-platform"; +import { Identifier } from "../../schema/identifier"; + +describe("Platform keyword lists", () => { + it("detects reserved keywords case-insensitively", () => { + const mysql = new MySQLPlatform(); + const sqlServer = new SQLServerPlatform(); + const oracle = new OraclePlatform(); + + expect(mysql.getReservedKeywordsList().isKeyword("select")).toBe(true); + expect(sqlServer.getReservedKeywordsList().isKeyword("transaction")).toBe(true); + expect(oracle.getReservedKeywordsList().isKeyword("group")).toBe(true); + }); + + it("reuses the same keyword list instance per platform", () => { + const mysql = new MySQLPlatform(); + + const listA = mysql.getReservedKeywordsList(); + const listB = mysql.getReservedKeywordsList(); + + expect(listA).toBe(listB); + }); + + it("quotes reserved identifiers via schema assets", () => { + const mysql = new MySQLPlatform(); + + expect(new Identifier("users").getQuotedName(mysql)).toBe("users"); + expect(new Identifier("select").getQuotedName(mysql)).toBe("`select`"); + expect(new Identifier("app.select").getQuotedName(mysql)).toBe("app.`select`"); + }); +}); diff --git a/src/__tests__/platforms/platform-parity.test.ts b/src/__tests__/platforms/platform-parity.test.ts index 64333bd..7c57c88 100644 --- a/src/__tests__/platforms/platform-parity.test.ts +++ b/src/__tests__/platforms/platform-parity.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { LockMode } from "../../lock-mode"; import { AbstractPlatform } from "../../platforms/abstract-platform"; import { DB2Platform } from "../../platforms/db2-platform"; import { MySQLPlatform } from "../../platforms/mysql-platform"; @@ -90,10 +91,10 @@ describe("Platform parity extensions", () => { it("applies SQL Server lock hints and identifier quoting", () => { const platform = new SQLServerPlatform(); - expect(platform.appendLockHint("users u", "pessimistic_read")).toBe( + expect(platform.appendLockHint("users u", LockMode.PESSIMISTIC_READ)).toBe( "users u WITH (HOLDLOCK, ROWLOCK)", ); - expect(platform.appendLockHint("users u", "pessimistic_write")).toBe( + expect(platform.appendLockHint("users u", LockMode.PESSIMISTIC_WRITE)).toBe( "users u WITH (UPDLOCK, ROWLOCK)", ); expect(platform.quoteSingleIdentifier("a]b")).toBe("[a]]b]"); diff --git a/src/__tests__/platforms/platforms.test.ts b/src/__tests__/platforms/platforms.test.ts index 0082df6..d9bfcdb 100644 --- a/src/__tests__/platforms/platforms.test.ts +++ b/src/__tests__/platforms/platforms.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { LockMode } from "../../lock-mode"; import { DB2Platform } from "../../platforms/db2-platform"; import { MySQLPlatform } from "../../platforms/mysql-platform"; import { OraclePlatform } from "../../platforms/oracle-platform"; @@ -32,10 +33,10 @@ describe("Platform parity", () => { it("sql server applies lock hints", () => { const platform = new SQLServerPlatform(); - expect(platform.appendLockHint("users u", "pessimistic_read")).toBe( + expect(platform.appendLockHint("users u", LockMode.PESSIMISTIC_READ)).toBe( "users u WITH (HOLDLOCK, ROWLOCK)", ); - expect(platform.appendLockHint("users u", "pessimistic_write")).toBe( + expect(platform.appendLockHint("users u", LockMode.PESSIMISTIC_WRITE)).toBe( "users u WITH (UPDLOCK, ROWLOCK)", ); }); diff --git a/src/__tests__/schema/schema-assets.test.ts b/src/__tests__/schema/schema-assets.test.ts new file mode 100644 index 0000000..c9f4188 --- /dev/null +++ b/src/__tests__/schema/schema-assets.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { MySQLPlatform } from "../../platforms/mysql-platform"; +import { ForeignKeyConstraint } from "../../schema/foreign-key-constraint"; +import { Index } from "../../schema/index"; +import { Schema } from "../../schema/schema"; +import { Table } from "../../schema/table"; +import { Types } from "../../types/types"; + +describe("Schema assets", () => { + it("builds table columns, indexes and foreign keys", () => { + const table = new Table("users"); + + table.addColumn("id", Types.INTEGER, { autoincrement: true, notnull: true }); + table.addColumn("email", Types.STRING, { length: 190, notnull: true }); + + const primary = table.setPrimaryKey(["id"]); + const unique = table.addUniqueIndex(["email"], "uniq_users_email"); + const foreign = table.addForeignKeyConstraint("accounts", ["id"], ["user_id"], { + onDelete: "CASCADE", + }); + + expect(table.hasPrimaryKey()).toBe(true); + expect(primary.getColumns()).toEqual(["id"]); + expect(unique.isUnique()).toBe(true); + expect(foreign.getForeignTableName()).toBe("accounts"); + expect(foreign.onDelete()).toBe("CASCADE"); + expect(table.getColumns()).toHaveLength(2); + expect(table.getIndexes()).toHaveLength(2); + expect(table.getForeignKeys()).toHaveLength(1); + }); + + it("supports index matching semantics", () => { + const lhs = new Index("idx_users_email", ["email"]); + const rhs = new Index("idx_users_email_2", ["email"]); + const unique = new Index("uniq_users_email", ["email"], true); + + expect(lhs.spansColumns(["email"])).toBe(true); + expect(lhs.isFulfilledBy(rhs)).toBe(true); + expect(unique.isFulfilledBy(lhs)).toBe(false); + }); + + it("tracks schema tables and sequences", () => { + const schema = new Schema(); + + const users = schema.createTable("users"); + users.addColumn("id", Types.INTEGER, { notnull: true }); + + schema.createSequence("users_seq", 10, 1); + + expect(schema.hasTable("users")).toBe(true); + expect(schema.getTable("users").getName()).toBe("users"); + expect(schema.hasSequence("users_seq")).toBe(true); + expect(schema.getSequence("users_seq").getAllocationSize()).toBe(10); + }); + + it("quotes keyword-backed index columns", () => { + const platform = new MySQLPlatform(); + const index = new Index("idx_select", ["select"]); + const fk = new ForeignKeyConstraint(["select"], "roles", ["id"], "fk_select_role"); + + expect(index.getQuotedColumns(platform)).toEqual(["`select`"]); + expect(fk.getQuotedLocalColumns(platform)).toEqual(["`select`"]); + }); +}); diff --git a/src/__tests__/schema/schema-comparator-editor.test.ts b/src/__tests__/schema/schema-comparator-editor.test.ts new file mode 100644 index 0000000..3f54323 --- /dev/null +++ b/src/__tests__/schema/schema-comparator-editor.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { Comparator } from "../../schema/comparator"; +import { ComparatorConfig } from "../../schema/comparator-config"; +import { Table } from "../../schema/table"; +import { UniqueConstraint } from "../../schema/unique-constraint"; +import { Types } from "../../types/types"; + +describe("Schema comparator and editors", () => { + it("creates tables through TableEditor and compares schema changes", () => { + const baseUsers = new Table("users"); + baseUsers.addColumn("id", Types.INTEGER); + + const modifiedUsers = new Table("users"); + modifiedUsers.addColumn("id", Types.INTEGER, { autoincrement: true }); + modifiedUsers.addColumn("email", Types.STRING, { length: 190 }); + + const comparator = new Comparator(new ComparatorConfig({ detectColumnRenames: true })); + const tableDiff = comparator.compareTables(baseUsers, modifiedUsers); + + expect(tableDiff).not.toBeNull(); + expect(tableDiff?.addedColumns.map((column) => column.getName())).toContain("email"); + }); + + it("supports unique constraint editing", () => { + const unique = new UniqueConstraint("uniq_users_email", ["email"], ["clustered"]); + + const edited = unique.edit().setName("uniq_users_email_v2").create(); + + expect(edited.getObjectName()).toBe("uniq_users_email_v2"); + expect(edited.isClustered()).toBe(true); + }); +}); diff --git a/src/__tests__/schema/schema-exception-parity.test.ts b/src/__tests__/schema/schema-exception-parity.test.ts new file mode 100644 index 0000000..ad0f0c7 --- /dev/null +++ b/src/__tests__/schema/schema-exception-parity.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it } from "vitest"; + +import { ObjectAlreadyExists } from "../../schema/collections/exception/object-already-exists"; +import { ObjectDoesNotExist } from "../../schema/collections/exception/object-does-not-exist"; +import { OptionallyUnqualifiedNamedObjectSet } from "../../schema/collections/optionally-unqualified-named-object-set"; +import { UnqualifiedNamedObjectSet } from "../../schema/collections/unqualified-named-object-set"; +import { ColumnEditor } from "../../schema/column-editor"; +import { + ColumnAlreadyExists, + ColumnDoesNotExist, + ForeignKeyDoesNotExist, + IncomparableNames, + IndexAlreadyExists, + IndexDoesNotExist, + IndexNameInvalid, + InvalidColumnDefinition, + InvalidForeignKeyConstraintDefinition, + InvalidIdentifier, + InvalidIndexDefinition, + InvalidPrimaryKeyConstraintDefinition, + InvalidSequenceDefinition, + InvalidState, + InvalidTableDefinition, + InvalidTableModification, + InvalidTableName, + InvalidUniqueConstraintDefinition, + InvalidViewDefinition, + NamespaceAlreadyExists, + NotImplemented, + PrimaryKeyAlreadyExists, + InvalidName as SchemaInvalidName, + SequenceAlreadyExists, + SequenceDoesNotExist, + TableAlreadyExists, + TableDoesNotExist, + UniqueConstraintDoesNotExist, + UnknownColumnOption, + UnsupportedName, + UnsupportedSchema, +} from "../../schema/exception"; +import { ForeignKeyConstraintEditor } from "../../schema/foreign-key-constraint-editor"; +import { IndexEditor } from "../../schema/index-editor"; +import { ExpectedDot } from "../../schema/name/parser/exception/expected-dot"; +import { ExpectedNextIdentifier } from "../../schema/name/parser/exception/expected-next-identifier"; +import { InvalidName as ParserInvalidName } from "../../schema/name/parser/exception/invalid-name"; +import { UnableToParseIdentifier } from "../../schema/name/parser/exception/unable-to-parse-identifier"; +import { PrimaryKeyConstraint } from "../../schema/primary-key-constraint"; +import { Schema } from "../../schema/schema"; +import { SequenceEditor } from "../../schema/sequence-editor"; +import { Table } from "../../schema/table"; +import { TableEditor } from "../../schema/table-editor"; +import { UniqueConstraint } from "../../schema/unique-constraint"; +import { ViewEditor } from "../../schema/view-editor"; +import { Types } from "../../types/types"; + +class NamedStub { + constructor(private readonly name: string) {} + + public getName(): string { + return this.name; + } +} + +class OptionallyNamedStub { + constructor(private readonly name: string | null) {} + + public getObjectName(): string | null { + return this.name; + } +} + +describe("Schema exception parity (best effort)", () => { + it("matches Doctrine-style exception factory messages", () => { + expect(InvalidColumnDefinition.nameNotSpecified().message).toBe( + "Column name is not specified.", + ); + expect(InvalidColumnDefinition.dataTypeNotSpecified("email").message).toBe( + "Data type is not specified for column email.", + ); + + expect(InvalidIndexDefinition.nameNotSet().message).toBe("Index name is not set."); + expect(InvalidIndexDefinition.columnsNotSet("idx_users_email").message).toBe( + "Columns are not set for index idx_users_email.", + ); + expect(InvalidIndexDefinition.fromNonPositiveColumnLength("email", 0).message).toBe( + "Indexed column length must be a positive integer, 0 given for column email.", + ); + + expect(InvalidForeignKeyConstraintDefinition.referencedTableNameNotSet(null).message).toBe( + "Referenced table name is not set for foreign key constraint .", + ); + expect( + InvalidForeignKeyConstraintDefinition.referencingColumnNamesNotSet("fk_users_roles").message, + ).toBe("Referencing column names are not set for foreign key constraint fk_users_roles."); + expect( + InvalidForeignKeyConstraintDefinition.referencedColumnNamesNotSet("fk_users_roles").message, + ).toBe("Referenced column names are not set for foreign key constraint fk_users_roles."); + + expect(InvalidUniqueConstraintDefinition.columnNamesAreNotSet(null).message).toBe( + "Column names are not set for unique constraint .", + ); + expect(InvalidPrimaryKeyConstraintDefinition.columnNamesNotSet().message).toBe( + "Primary key constraint column names are not set.", + ); + expect(InvalidSequenceDefinition.nameNotSet().message).toBe("Sequence name is not set."); + expect(InvalidSequenceDefinition.fromNegativeCacheSize(-1).message).toBe( + "Sequence cache size must be a non-negative integer, -1 given.", + ); + + expect( + UnsupportedSchema.sqliteMissingForeignKeyConstraintReferencedColumns(null, "users", "roles") + .message, + ).toBe( + 'Unable to introspect foreign key constraint on table "users" because the referenced column names are omitted, and the referenced table "roles" does not exist or does not have a primary key.', + ); + expect( + UnsupportedSchema.sqliteMissingForeignKeyConstraintReferencedColumns( + "fk_users_roles", + "users", + "roles", + ).message, + ).toContain('constraint "fk_users_roles" on table "users"'); + + expect(TableAlreadyExists.new("users").message).toBe( + 'The table with name "users" already exists.', + ); + expect(TableDoesNotExist.new("users").message).toBe( + 'There is no table with name "users" in the schema.', + ); + expect(SequenceAlreadyExists.new("users_seq").message).toBe( + 'The sequence "users_seq" already exists.', + ); + expect(SequenceDoesNotExist.new("users_seq").message).toBe( + 'There exists no sequence with the name "users_seq".', + ); + expect(NamespaceAlreadyExists.new("app").message).toBe( + 'The namespace with name "app" already exists.', + ); + expect(UnknownColumnOption.new("foo").message).toBe( + 'The "foo" column option is not supported.', + ); + }); + + it("covers remaining schema exception factory messages", () => { + expect(ColumnAlreadyExists.new("users", "email").message).toBe( + 'The column "email" on table "users" already exists.', + ); + expect(ColumnDoesNotExist.new("email", "users").message).toBe( + 'There is no column with name "email" on table "users".', + ); + expect(IndexAlreadyExists.new("idx_users_email", "users").message).toBe( + 'An index with name "idx_users_email" was already defined on table "users".', + ); + expect(IndexDoesNotExist.new("idx_users_email", "users").message).toBe( + 'Index "idx_users_email" does not exist on table "users".', + ); + expect(IndexNameInvalid.new("idx-users-email").message).toBe( + 'Invalid index name "idx-users-email" given, has to be [a-zA-Z0-9_].', + ); + expect(ForeignKeyDoesNotExist.new("fk_users_roles", "users").message).toBe( + 'There exists no foreign key with the name "fk_users_roles" on table "users".', + ); + expect(InvalidIdentifier.fromEmpty().message).toBe("Identifier cannot be empty."); + expect(SchemaInvalidName.fromEmpty().message).toBe("Name cannot be empty."); + expect(InvalidTableName.new("users posts").message).toBe( + 'Invalid table name specified "users posts".', + ); + expect(NotImplemented.fromMethod("MySchemaManager", "listTables").message).toBe( + "Class MySchemaManager does not implement method listTables().", + ); + expect(PrimaryKeyAlreadyExists.new("users").message).toBe( + 'Primary key was already defined on table "users".', + ); + expect(UniqueConstraintDoesNotExist.new("uniq_users_email", "users").message).toBe( + 'There exists no unique constraint with the name "uniq_users_email" on table "users".', + ); + expect(UnsupportedName.fromNonNullSchemaName("app", "createTable").message).toBe( + 'createTable() does not accept schema names, "app" given.', + ); + expect(UnsupportedName.fromNullSchemaName("createTable").message).toBe( + "createTable() requires a schema name, null given.", + ); + expect(IncomparableNames.fromOptionallyQualifiedNames("users", "app.users").message).toBe( + "Non-equally qualified names are incomparable: users, app.users.", + ); + }); + + it("matches Doctrine-style InvalidState messages", () => { + expect(InvalidState.objectNameNotInitialized().message).toBe( + "Object name has not been initialized.", + ); + expect(InvalidState.indexHasInvalidType("idx").message).toBe('Index "idx" has invalid type.'); + expect(InvalidState.uniqueConstraintHasEmptyColumnNames("uniq").message).toBe( + 'Unique constraint "uniq" has no column names.', + ); + expect(InvalidState.tableHasInvalidPrimaryKeyConstraint("users").message).toBe( + 'Table "users" has invalid primary key constraint.', + ); + expect(InvalidState.indexHasInvalidPredicate("idx").message).toBe( + 'Index "idx" has invalid predicate.', + ); + expect(InvalidState.indexHasInvalidColumns("idx").message).toBe( + 'Index "idx" has invalid columns.', + ); + expect(InvalidState.foreignKeyConstraintHasInvalidReferencedTableName("fk").message).toBe( + 'Foreign key constraint "fk" has invalid referenced table name.', + ); + expect(InvalidState.foreignKeyConstraintHasInvalidReferencingColumnNames("fk").message).toBe( + 'Foreign key constraint "fk" has one or more invalid referencing column names.', + ); + expect(InvalidState.foreignKeyConstraintHasInvalidReferencedColumnNames("fk").message).toBe( + 'Foreign key constraint "fk" has one or more invalid referenced column name.', + ); + expect(InvalidState.foreignKeyConstraintHasInvalidMatchType("fk").message).toBe( + 'Foreign key constraint "fk" has invalid match type.', + ); + expect(InvalidState.foreignKeyConstraintHasInvalidOnUpdateAction("fk").message).toBe( + 'Foreign key constraint "fk" has invalid ON UPDATE action.', + ); + expect(InvalidState.foreignKeyConstraintHasInvalidOnDeleteAction("fk").message).toBe( + 'Foreign key constraint "fk" has invalid ON DELETE action.', + ); + expect(InvalidState.foreignKeyConstraintHasInvalidDeferrability("fk").message).toBe( + 'Foreign key constraint "fk" has invalid deferrability.', + ); + expect(InvalidState.uniqueConstraintHasInvalidColumnNames("uq").message).toBe( + 'Unique constraint "uq" has one or more invalid column names.', + ); + expect(InvalidState.tableDiffContainsUnnamedDroppedForeignKeyConstraints().message).toBe( + "Table diff contains unnamed dropped foreign key constraints", + ); + }); + + it("preserves cause on InvalidTableModification factory methods", () => { + const previous = ObjectAlreadyExists.new("email"); + const error = InvalidTableModification.columnAlreadyExists("users", previous); + + expect(error).toBeInstanceOf(InvalidTableModification); + expect(error.message).toBe("Column email already exists on table users."); + expect((error as Error & { cause?: unknown }).cause).toBe(previous); + + const missing = ObjectDoesNotExist.new("idx_users_email"); + const indexMissing = InvalidTableModification.indexDoesNotExist("users", missing); + expect(indexMissing.message).toBe("Index idx_users_email does not exist on table users."); + expect((indexMissing as Error & { cause?: unknown }).cause).toBe(missing); + + expect(InvalidTableModification.primaryKeyConstraintAlreadyExists(null).message).toBe( + "Primary key constraint already exists on table .", + ); + expect( + InvalidTableModification.foreignKeyConstraintReferencingColumnDoesNotExist( + "users", + null, + "role_id", + ).message, + ).toBe( + "Referencing column role_id of foreign key constraint does not exist on table users.", + ); + + expect( + InvalidTableModification.columnDoesNotExist("users", ObjectDoesNotExist.new("name")).message, + ).toBe("Column name does not exist on table users."); + expect( + InvalidTableModification.indexAlreadyExists("users", ObjectAlreadyExists.new("idx")).message, + ).toBe("Index idx already exists on table users."); + expect(InvalidTableModification.primaryKeyConstraintDoesNotExist("users").message).toBe( + "Primary key constraint does not exist on table users.", + ); + + const uniqueMissing = ObjectDoesNotExist.new("uniq_users_email"); + const uniqueError = InvalidTableModification.uniqueConstraintDoesNotExist( + "users", + uniqueMissing, + ); + expect(uniqueError.message).toBe( + "Unique constraint uniq_users_email does not exist on table users.", + ); + expect((uniqueError as Error & { cause?: unknown }).cause).toBe(uniqueMissing); + + expect( + InvalidTableModification.uniqueConstraintAlreadyExists( + "users", + ObjectAlreadyExists.new("uniq_users_email"), + ).message, + ).toBe("Unique constraint uniq_users_email already exists on table users."); + expect( + InvalidTableModification.foreignKeyConstraintAlreadyExists( + "users", + ObjectAlreadyExists.new("fk_users_roles"), + ).message, + ).toBe("Foreign key constraint fk_users_roles already exists on table users."); + expect( + InvalidTableModification.foreignKeyConstraintDoesNotExist( + "users", + ObjectDoesNotExist.new("fk_users_roles"), + ).message, + ).toBe("Foreign key constraint fk_users_roles does not exist on table users."); + expect( + InvalidTableModification.indexedColumnDoesNotExist("users", "idx_users_email", "email") + .message, + ).toBe("Column email referenced by index idx_users_email does not exist on table users."); + expect( + InvalidTableModification.primaryKeyConstraintColumnDoesNotExist("users", "primary", "id") + .message, + ).toBe("Column id referenced by primary key constraint primary does not exist on table users."); + expect( + InvalidTableModification.uniqueConstraintColumnDoesNotExist( + "users", + "uniq_users_email", + "email", + ).message, + ).toBe( + "Column email referenced by unique constraint uniq_users_email does not exist on table users.", + ); + expect(InvalidTableModification.columnAlreadyExists("users", new Error("boom")).message).toBe( + "Column already exists on table users.", + ); + }); + + it("implements Doctrine-style collection exception helpers", () => { + const already = ObjectAlreadyExists.new("email"); + const missing = ObjectDoesNotExist.new("email"); + + expect(already.message).toBe("Object email already exists."); + expect(already.getObjectName().toString()).toBe("email"); + expect(missing.message).toBe("Object email does not exist."); + expect(missing.getObjectName().toString()).toBe("email"); + }); + + it("throws typed schema exceptions from editors and schema objects", () => { + expect(() => new ColumnEditor().create()).toThrow(InvalidColumnDefinition); + expect(() => new ColumnEditor().create()).toThrowError("Column name is not specified."); + + const missingTypeEditor = new ColumnEditor().setName("email"); + expect(() => missingTypeEditor.create()).toThrow(InvalidColumnDefinition); + expect(() => missingTypeEditor.create()).toThrowError( + "Data type is not specified for column email.", + ); + + expect(() => new IndexEditor().create()).toThrow(InvalidIndexDefinition); + expect(() => new IndexEditor().setName("idx").create()).toThrow(InvalidIndexDefinition); + expect(() => + new IndexEditor() + .setName("idx") + .setColumns("email") + .setOptions({ lengths: [0] }) + .create(), + ).toThrow(InvalidIndexDefinition); + + expect(() => new ForeignKeyConstraintEditor().create()).toThrow( + InvalidForeignKeyConstraintDefinition, + ); + expect(() => + new ForeignKeyConstraintEditor().setName("fk").setReferencingColumnNames("id").create(), + ).toThrowError("Referenced table name is not set for foreign key constraint fk."); + expect(() => + new ForeignKeyConstraintEditor().setName("fk").setReferencedTableName("roles").create(), + ).toThrowError("Referencing column names are not set for foreign key constraint fk."); + expect(() => + new ForeignKeyConstraintEditor() + .setName("fk") + .setReferencingColumnNames("role_id") + .setReferencedTableName("roles") + .create(), + ).toThrowError("Referenced column names are not set for foreign key constraint fk."); + + expect(() => new SequenceEditor().create()).toThrow(InvalidSequenceDefinition); + expect(() => new SequenceEditor().setName("s").setAllocationSize(-1).create()).toThrowError( + "Sequence cache size must be a non-negative integer, -1 given.", + ); + + expect(() => new ViewEditor().create()).toThrow(InvalidViewDefinition); + expect(() => new ViewEditor().setName("v_users").create()).toThrowError( + "SQL is not set for view v_users.", + ); + + expect(() => new TableEditor().create()).toThrow(InvalidTableDefinition); + expect(() => new TableEditor().setName("users").create()).toThrowError( + "Columns are not set for table users.", + ); + + expect(() => new PrimaryKeyConstraint(null, [], false)).toThrow( + InvalidPrimaryKeyConstraintDefinition, + ); + expect(() => new UniqueConstraint("uniq_users_email", [])).toThrow( + InvalidUniqueConstraintDefinition, + ); + + const schema = new Schema(); + schema.createNamespace("app"); + expect(() => schema.createNamespace("app")).toThrow(NamespaceAlreadyExists); + + const users = new Table("users"); + schema.addTable(users); + expect(() => schema.addTable(new Table("users"))).toThrow(TableAlreadyExists); + expect(() => schema.getTable("posts")).toThrow(TableDoesNotExist); + + schema.createSequence("users_seq"); + expect(() => schema.addSequence(schema.getSequence("users_seq"))).toThrow( + SequenceAlreadyExists, + ); + expect(() => schema.getSequence("missing_seq")).toThrow(SequenceDoesNotExist); + + users.addColumn("id", Types.INTEGER); + expect(() => users.addColumn("id", Types.INTEGER)).toThrow(ColumnAlreadyExists); + expect(() => users.getColumn("email")).toThrow(ColumnDoesNotExist); + + expect(() => users.getPrimaryKey()).toThrow(InvalidState); + users.setPrimaryKey(["id"]); + expect(() => users.setPrimaryKey(["id"])).toThrow(PrimaryKeyAlreadyExists); + + expect(() => users.getIndex("idx_missing")).toThrow(IndexDoesNotExist); + expect(() => users.dropIndex("idx_missing")).toThrow(IndexDoesNotExist); + + expect(() => users.getForeignKey("fk_missing")).toThrow(ForeignKeyDoesNotExist); + expect(() => users.removeForeignKey("fk_missing")).toThrow(ForeignKeyDoesNotExist); + }); + + it("throws Doctrine-style parser exception messages", () => { + expect(ExpectedDot.new(3, "/").message).toBe('Expected dot at position 3, got "/".'); + expect(ExpectedNextIdentifier.new().message).toBe( + "Unexpected end of input. Next identifier expected.", + ); + expect(ParserInvalidName.forUnqualifiedName(2).message).toBe( + "An unqualified name must consist of one identifier, 2 given.", + ); + expect(ParserInvalidName.forOptionallyQualifiedName(3).message).toBe( + "An optionally qualified name must consist of one or two identifiers, 3 given.", + ); + expect(UnableToParseIdentifier.new(9).message).toBe("Unable to parse identifier at offset 9."); + }); + + it("collection sets use Doctrine-style collection exceptions", () => { + const set = new UnqualifiedNamedObjectSet(); + set.add(new NamedStub("users")); + + expect(() => set.add(new NamedStub("users"))).toThrow(ObjectAlreadyExists); + expect(() => set.getByName("missing")).toThrow(ObjectDoesNotExist); + expect(() => set.removeByName("missing")).toThrow(ObjectDoesNotExist); + + const optionalSet = new OptionallyUnqualifiedNamedObjectSet(); + optionalSet.add(new OptionallyNamedStub(null)); + optionalSet.add(new OptionallyNamedStub("uq_users_email")); + + expect(() => optionalSet.add(new OptionallyNamedStub("uq_users_email"))).toThrow( + ObjectAlreadyExists, + ); + expect(() => optionalSet.getByName("missing_uq")).toThrow(ObjectDoesNotExist); + }); + + it("rejects unknown column options like Doctrine", () => { + expect(() => + new Table("users").addColumn("email", Types.STRING, { definitelyUnknown: true }), + ).toThrow(UnknownColumnOption); + }); +}); diff --git a/src/__tests__/schema/schema-file-parity.test.ts b/src/__tests__/schema/schema-file-parity.test.ts new file mode 100644 index 0000000..b2dc609 --- /dev/null +++ b/src/__tests__/schema/schema-file-parity.test.ts @@ -0,0 +1,73 @@ +import { readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +function listFiles(root: string, ext: string): string[] { + const out: string[] = []; + + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const absolutePath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + walk(absolutePath); + continue; + } + + if (entry.isFile() && absolutePath.endsWith(ext)) { + out.push(path.relative(root, absolutePath)); + } + } + }; + + walk(root); + return out.sort(); +} + +function normalizeAcronyms(input: string): string { + return input + .replaceAll("MySQL", "Mysql") + .replaceAll("PostgreSQL", "PostgreSql") + .replaceAll("SQLServer", "SqlServer") + .replaceAll("SQLite", "Sqlite") + .replaceAll("DB2", "Db2"); +} + +function toKebab(segment: string): string { + const normalized = normalizeAcronyms(segment); + + return normalized + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replaceAll("_", "-") + .toLowerCase(); +} + +describe("Schema file parity", () => { + it("covers Doctrine Schema file map with best-effort TS parity", () => { + const workspaceRoot = process.cwd(); + const referenceRoot = path.join(workspaceRoot, "references/dbal/src/Schema"); + const targetRoot = path.join(workspaceRoot, "src/schema"); + + if (!statSync(referenceRoot).isDirectory()) { + throw new Error("Reference Doctrine schema folder not found."); + } + + const referenceFiles = listFiles(referenceRoot, ".php").map((relativePath) => { + const withoutExt = relativePath.slice(0, -4); + return withoutExt + .split(path.sep) + .map((segment) => toKebab(segment)) + .join("/"); + }); + + const targetFiles = listFiles(targetRoot, ".ts").map((relativePath) => + relativePath.slice(0, -3), + ); + + const missing = referenceFiles.filter((referenceFile) => !targetFiles.includes(referenceFile)); + + expect(missing).toEqual([]); + }); +}); diff --git a/src/__tests__/schema/schema-manager.test.ts b/src/__tests__/schema/schema-manager.test.ts new file mode 100644 index 0000000..80450fe --- /dev/null +++ b/src/__tests__/schema/schema-manager.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { Configuration } from "../../configuration"; +import { Connection } from "../../connection"; +import { + type Driver, + type DriverConnection, + type DriverExecutionResult, + type DriverQueryResult, + ParameterBindingStyle, +} from "../../driver"; +import type { + ExceptionConverter, + ExceptionConverterContext, +} from "../../driver/api/exception-converter"; +import { DriverException } from "../../exception/driver-exception"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; +import { AbstractSchemaManager } from "../../schema/abstract-schema-manager"; +import { SchemaManagerFactory } from "../../schema/schema-manager-factory"; +import type { CompiledQuery } from "../../types"; + +class NoopExceptionConverter implements ExceptionConverter { + public convert(error: unknown, context: ExceptionConverterContext): DriverException { + return new DriverException("driver failure", { + cause: error, + driverName: "schema-spy", + operation: context.operation, + parameters: context.query?.parameters, + sql: context.query?.sql, + }); + } +} + +class SchemaSpyConnection implements DriverConnection { + public async executeQuery(query: CompiledQuery): Promise { + if (query.sql.includes("TABLE_TYPE = 'BASE TABLE'")) { + return { + rows: [{ TABLE_NAME: "users" }, { TABLE_NAME: "posts" }], + }; + } + + if (query.sql.includes("TABLE_TYPE = 'VIEW'")) { + return { + rows: [{ TABLE_NAME: "active_users" }], + }; + } + + return { rows: [] }; + } + + public async executeStatement(_query: CompiledQuery): Promise { + return { affectedRows: 0, insertId: null }; + } + + public async beginTransaction(): Promise {} + + public async commit(): Promise {} + + public async rollBack(): Promise {} + + public async getServerVersion(): Promise { + return "8.0.0"; + } + + public async close(): Promise {} + + public getNativeConnection(): unknown { + return this; + } +} + +class SchemaSpyDriver implements Driver { + public readonly name = "schema-spy"; + public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; + private readonly converter = new NoopExceptionConverter(); + + public async connect(_params: Record): Promise { + return new SchemaSpyConnection(); + } + + public getExceptionConverter(): ExceptionConverter { + return this.converter; + } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } +} + +class CustomSchemaManager extends AbstractSchemaManager { + protected getListTableNamesSQL(): string { + return "SELECT 'custom_table'"; + } +} + +class CustomSchemaManagerFactory implements SchemaManagerFactory { + public createSchemaManager(connection: Connection): AbstractSchemaManager { + return new CustomSchemaManager(connection, connection.getDatabasePlatform()); + } +} + +describe("Connection schema manager integration", () => { + it("creates platform schema manager by default", async () => { + const connection = new Connection({}, new SchemaSpyDriver()); + const manager = connection.createSchemaManager(); + + expect(manager).toBeInstanceOf(AbstractSchemaManager); + await expect(manager.listTableNames()).resolves.toEqual(["users", "posts"]); + await expect(manager.listViewNames()).resolves.toEqual(["active_users"]); + await expect(manager.tableExists("users")).resolves.toBe(true); + }); + + it("uses custom schema manager factory from configuration", () => { + const configuration = new Configuration({ + schemaManagerFactory: new CustomSchemaManagerFactory(), + }); + + const connection = new Connection({}, new SchemaSpyDriver(), configuration); + const manager = connection.createSchemaManager(); + + expect(manager).toBeInstanceOf(CustomSchemaManager); + }); +}); diff --git a/src/__tests__/schema/schema-name-introspection-parity.test.ts b/src/__tests__/schema/schema-name-introspection-parity.test.ts new file mode 100644 index 0000000..6ca6dec --- /dev/null +++ b/src/__tests__/schema/schema-name-introspection-parity.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; + +import { AbstractPlatform } from "../../platforms/abstract-platform"; +import { Column } from "../../schema/column"; +import { IncomparableNames, InvalidIndexDefinition } from "../../schema/exception"; +import { Deferrability } from "../../schema/foreign-key-constraint/deferrability"; +import { MatchType } from "../../schema/foreign-key-constraint/match-type"; +import { ReferentialAction } from "../../schema/foreign-key-constraint/referential-action"; +import { IndexType, IndexedColumn } from "../../schema/index/index"; +import { ForeignKeyConstraintColumnMetadataProcessor } from "../../schema/introspection/metadata-processor/foreign-key-constraint-column-metadata-processor"; +import { IndexColumnMetadataProcessor } from "../../schema/introspection/metadata-processor/index-column-metadata-processor"; +import { PrimaryKeyConstraintColumnMetadataProcessor } from "../../schema/introspection/metadata-processor/primary-key-constraint-column-metadata-processor"; +import { SequenceMetadataProcessor } from "../../schema/introspection/metadata-processor/sequence-metadata-processor"; +import { ViewMetadataProcessor } from "../../schema/introspection/metadata-processor/view-metadata-processor"; +import { DatabaseMetadataRow } from "../../schema/metadata/database-metadata-row"; +import { ForeignKeyConstraintColumnMetadataRow } from "../../schema/metadata/foreign-key-constraint-column-metadata-row"; +import { IndexColumnMetadataRow } from "../../schema/metadata/index-column-metadata-row"; +import { PrimaryKeyConstraintColumnRow } from "../../schema/metadata/primary-key-constraint-column-row"; +import { SchemaMetadataRow } from "../../schema/metadata/schema-metadata-row"; +import { SequenceMetadataRow } from "../../schema/metadata/sequence-metadata-row"; +import { TableColumnMetadataRow } from "../../schema/metadata/table-column-metadata-row"; +import { TableMetadataRow } from "../../schema/metadata/table-metadata-row"; +import { ViewMetadataRow } from "../../schema/metadata/view-metadata-row"; +import { GenericName } from "../../schema/name/generic-name"; +import { Identifier as SchemaNameIdentifier } from "../../schema/name/identifier"; +import { OptionallyQualifiedName } from "../../schema/name/optionally-qualified-name"; +import { GenericNameParser } from "../../schema/name/parser/generic-name-parser"; +import { OptionallyQualifiedNameParser } from "../../schema/name/parser/optionally-qualified-name-parser"; +import { UnqualifiedNameParser } from "../../schema/name/parser/unqualified-name-parser"; +import { Parsers } from "../../schema/name/parsers"; +import { UnqualifiedName } from "../../schema/name/unqualified-name"; +import { UnquotedIdentifierFolding } from "../../schema/name/unquoted-identifier-folding"; +import { TransactionIsolationLevel } from "../../transaction-isolation-level"; +import { Types } from "../../types/types"; + +class TestPlatform extends AbstractPlatform { + constructor(private readonly folding: UnquotedIdentifierFolding) { + super(); + } + + public getUnquotedIdentifierFolding(): UnquotedIdentifierFolding { + return this.folding; + } + + public getLocateExpression(string: string, substring: string, start?: string | null): string { + return `LOCATE(${substring}, ${string}${start ? `, ${start}` : ""})`; + } + + public getDateDiffExpression(date1: string, date2: string): string { + return `DATEDIFF(${date1}, ${date2})`; + } + + public getSetTransactionIsolationSQL(level: TransactionIsolationLevel): string { + return `SET TRANSACTION ISOLATION LEVEL ${level}`; + } +} + +describe("Schema name + introspection parity (best effort)", () => { + it("ports IndexedColumn semantics from Doctrine", () => { + const column = new IndexedColumn(UnqualifiedName.quoted("email"), 64); + + expect(column.getColumnName().toString()).toBe('"email"'); + expect(column.getLength()).toBe(64); + + expect(() => new IndexedColumn("email", 0)).toThrow(InvalidIndexDefinition); + }); + + it("ports name value objects and identifier folding semantics", () => { + const lowerPlatform = new TestPlatform(UnquotedIdentifierFolding.LOWER); + + const unquoted = SchemaNameIdentifier.unquoted("Users"); + const quoted = SchemaNameIdentifier.quoted("Users"); + + expect(unquoted.toNormalizedValue(UnquotedIdentifierFolding.LOWER)).toBe("users"); + expect(quoted.toNormalizedValue(UnquotedIdentifierFolding.LOWER)).toBe("Users"); + expect(unquoted.toSQL(lowerPlatform)).toBe('"users"'); + expect(quoted.toSQL(lowerPlatform)).toBe('"Users"'); + expect(SchemaNameIdentifier.quoted('a"b').toString()).toBe('"a""b"'); + expect( + SchemaNameIdentifier.unquoted("Users").equals( + SchemaNameIdentifier.unquoted("users"), + UnquotedIdentifierFolding.LOWER, + ), + ).toBe(true); + + const generic = new GenericName( + SchemaNameIdentifier.unquoted("public"), + SchemaNameIdentifier.unquoted("users"), + ); + expect(generic.toString()).toBe("public.users"); + expect(generic.toSQL(lowerPlatform)).toBe('"public"."users"'); + + const unqualified = UnqualifiedName.unquoted("Users"); + expect( + unqualified.equals(UnqualifiedName.unquoted("users"), UnquotedIdentifierFolding.LOWER), + ).toBe(true); + + const name1 = OptionallyQualifiedName.unquoted("users", "public"); + const name2 = OptionallyQualifiedName.unquoted("USERS", "PUBLIC"); + expect(name1.equals(name2, UnquotedIdentifierFolding.LOWER)).toBe(true); + expect(() => + OptionallyQualifiedName.unquoted("users").equals( + OptionallyQualifiedName.unquoted("users", "public"), + UnquotedIdentifierFolding.NONE, + ), + ).toThrow(IncomparableNames); + }); + + it("ports Doctrine-style name parser behavior", () => { + const genericParser = new GenericNameParser(); + const parsed = genericParser.parse('[app].`Users`."Email"'); + + expect(parsed.getIdentifiers().map((identifier) => identifier.toString())).toEqual([ + '"app"', + '"Users"', + '"Email"', + ]); + + const optionallyQualified = new OptionallyQualifiedNameParser(genericParser).parse("app.users"); + expect(optionallyQualified.getQualifier()?.toString()).toBe("app"); + expect(optionallyQualified.getUnqualifiedName().toString()).toBe("users"); + + const unqualified = new UnqualifiedNameParser(genericParser).parse("users"); + expect(unqualified.toString()).toBe("users"); + + expect(Parsers.getGenericNameParser()).toBe(Parsers.getGenericNameParser()); + expect(Parsers.getUnqualifiedNameParser()).toBe(Parsers.getUnqualifiedNameParser()); + expect(Parsers.getOptionallyQualifiedNameParser()).toBe( + Parsers.getOptionallyQualifiedNameParser(), + ); + }); + + it("ports metadata row DTO semantics", () => { + const column = new Column("email", Types.STRING); + + expect(new DatabaseMetadataRow("appdb").getDatabaseName()).toBe("appdb"); + expect(new SchemaMetadataRow("public").getSchemaName()).toBe("public"); + + const tableColumnRow = new TableColumnMetadataRow("public", "users", column); + expect(tableColumnRow.getSchemaName()).toBe("public"); + expect(tableColumnRow.getTableName()).toBe("users"); + expect(tableColumnRow.getColumn()).toBe(column); + + const indexRow = new IndexColumnMetadataRow( + "public", + "users", + "idx_users_email", + IndexType.UNIQUE, + true, + "email IS NOT NULL", + "email", + 32, + ); + expect(indexRow.getIndexName()).toBe("idx_users_email"); + expect(indexRow.getType()).toBe(IndexType.UNIQUE); + expect(indexRow.isClustered()).toBe(true); + expect(indexRow.getPredicate()).toBe("email IS NOT NULL"); + expect(indexRow.getColumnLength()).toBe(32); + + const pkRow = new PrimaryKeyConstraintColumnRow("public", "users", "pk_users", true, "id"); + expect(pkRow.getConstraintName()).toBe("pk_users"); + expect(pkRow.isClustered()).toBe(true); + + const tableRow = new TableMetadataRow("public", "users", { engine: "InnoDB" }); + expect(tableRow.getOptions()).toEqual({ engine: "InnoDB" }); + expect(tableRow.getOptions()).not.toBe(tableRow.getOptions()); + + const sequenceRow = new SequenceMetadataRow("public", "users_seq", 10, 1, 5); + expect(sequenceRow.getSequenceName()).toBe("users_seq"); + expect(sequenceRow.getCacheSize()).toBe(5); + + const viewRow = new ViewMetadataRow("public", "v_users", "SELECT 1"); + expect(viewRow.getViewName()).toBe("v_users"); + expect(viewRow.getDefinition()).toBe("SELECT 1"); + + const fkWithIdFallback = new ForeignKeyConstraintColumnMetadataRow( + "public", + "users", + null, + "fk_users_roles", + "public", + "roles", + MatchType.SIMPLE, + ReferentialAction.NO_ACTION, + ReferentialAction.CASCADE, + true, + false, + "role_id", + "id", + ); + expect(fkWithIdFallback.getId()).toBe("fk_users_roles"); + + expect( + () => + new ForeignKeyConstraintColumnMetadataRow( + "public", + "users", + null, + null, + "public", + "roles", + MatchType.SIMPLE, + ReferentialAction.NO_ACTION, + ReferentialAction.CASCADE, + false, + false, + "role_id", + "id", + ), + ).toThrowError("Either the id or name must be set to a non-null value."); + }); + + it("ports metadata processors to build schema objects", () => { + const indexProcessor = new IndexColumnMetadataProcessor(); + const indexEditor = indexProcessor.initializeEditor( + new IndexColumnMetadataRow( + null, + "users", + "idx_users_email", + IndexType.UNIQUE, + true, + "email IS NOT NULL", + "email", + 16, + ), + ); + indexProcessor.applyRow( + indexEditor, + new IndexColumnMetadataRow( + null, + "users", + "idx_users_email", + IndexType.UNIQUE, + true, + "email IS NOT NULL", + "email", + 16, + ), + ); + const index = indexEditor.create(); + expect(index.isUnique()).toBe(true); + expect(index.getOption("clustered")).toBe(true); + expect(index.getOption("where")).toBe("email IS NOT NULL"); + + const pkProcessor = new PrimaryKeyConstraintColumnMetadataProcessor(); + const pkEditor = pkProcessor.initializeEditor( + new PrimaryKeyConstraintColumnRow(null, "users", null, true, "id"), + ); + pkProcessor.applyRow( + pkEditor, + new PrimaryKeyConstraintColumnRow(null, "users", null, true, "id"), + ); + const primaryKey = pkEditor.create(); + expect(primaryKey.isClustered()).toBe(true); + expect(primaryKey.getColumnNames()).toEqual(['"id"']); + + const fkProcessor = new ForeignKeyConstraintColumnMetadataProcessor("public"); + const fkRow1 = new ForeignKeyConstraintColumnMetadataRow( + "public", + "users", + 1, + "fk_users_roles", + "public", + "roles", + MatchType.SIMPLE, + ReferentialAction.CASCADE, + ReferentialAction.RESTRICT, + true, + false, + "role_id", + "id", + ); + const fkRow2 = new ForeignKeyConstraintColumnMetadataRow( + "public", + "users", + 1, + "fk_users_roles", + "public", + "roles", + MatchType.SIMPLE, + ReferentialAction.CASCADE, + ReferentialAction.RESTRICT, + true, + false, + "tenant_id", + "tenant_id", + ); + const fkEditor = fkProcessor.initializeEditor(fkRow1); + fkProcessor.applyRow(fkEditor, fkRow1); + fkProcessor.applyRow(fkEditor, fkRow2); + const fk = fkEditor.create(); + expect(fk.getForeignTableName()).toBe('"roles"'); + expect(fk.getReferencingColumnNames()).toEqual(["role_id", "tenant_id"]); + expect(fk.getReferencedColumnNames()).toEqual(["id", "tenant_id"]); + expect(fk.onUpdate()).toBe(ReferentialAction.CASCADE); + expect(fk.onDelete()).toBe(ReferentialAction.RESTRICT); + expect(fk.getOption("match")).toBe(MatchType.SIMPLE); + expect(fk.getOption("deferrability")).toBe(Deferrability.DEFERRABLE); + + const sequence = new SequenceMetadataProcessor().createObject( + new SequenceMetadataRow("public", "users_seq", 10, 1, 7), + ); + expect(sequence.getName()).toBe("public.users_seq"); + expect(sequence.getAllocationSize()).toBe(10); + expect(sequence.getCacheSize()).toBe(7); + + const view = new ViewMetadataProcessor().createObject( + new ViewMetadataRow("public", "v_users", "SELECT * FROM users"), + ); + expect(view.getName()).toBe("public.v_users"); + expect(view.getSql()).toBe("SELECT * FROM users"); + }); +}); diff --git a/src/configuration.ts b/src/configuration.ts index 662b358..4278e86 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,17 +1,24 @@ import type { DriverMiddleware } from "./driver"; +import type { SchemaManagerFactory } from "./schema/schema-manager-factory"; interface ConfigurationOptions { autoCommit?: boolean; middlewares?: DriverMiddleware[]; + schemaAssetsFilter?: (assetName: string) => boolean; + schemaManagerFactory?: SchemaManagerFactory; } export class Configuration { private autoCommit: boolean; private middlewares: DriverMiddleware[]; + private schemaAssetsFilter: (assetName: string) => boolean; + private schemaManagerFactory: SchemaManagerFactory | null; constructor(options?: ConfigurationOptions) { this.autoCommit = options?.autoCommit ?? true; this.middlewares = options?.middlewares ?? []; + this.schemaAssetsFilter = options?.schemaAssetsFilter ?? (() => true); + this.schemaManagerFactory = options?.schemaManagerFactory ?? null; } public getAutoCommit(): boolean { @@ -36,4 +43,22 @@ export class Configuration { this.middlewares.push(middleware); return this; } + + public getSchemaAssetsFilter(): (assetName: string) => boolean { + return this.schemaAssetsFilter; + } + + public setSchemaAssetsFilter(schemaAssetsFilter: (assetName: string) => boolean): this { + this.schemaAssetsFilter = schemaAssetsFilter; + return this; + } + + public getSchemaManagerFactory(): SchemaManagerFactory | null { + return this.schemaManagerFactory; + } + + public setSchemaManagerFactory(schemaManagerFactory: SchemaManagerFactory | null): this { + this.schemaManagerFactory = schemaManagerFactory; + return this; + } } diff --git a/src/connection.ts b/src/connection.ts index eb00f49..8d99401 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -20,6 +20,9 @@ import { Query } from "./query"; import { ExpressionBuilder } from "./query/expression/expression-builder"; import { QueryBuilder } from "./query/query-builder"; import { Result } from "./result"; +import type { AbstractSchemaManager } from "./schema/abstract-schema-manager"; +import { DefaultSchemaManagerFactory } from "./schema/default-schema-manager-factory"; +import type { SchemaManagerFactory } from "./schema/schema-manager-factory"; import { Parser, type SQLParser, type Visitor } from "./sql/parser"; import { Statement, type StatementExecutor } from "./statement"; import type { @@ -48,6 +51,7 @@ export class Connection implements StatementExecutor { private exceptionConverter: ExceptionConverter | null = null; private databasePlatform: AbstractPlatform | null = null; private parser: SQLParser | null = null; + private readonly schemaManagerFactory: SchemaManagerFactory; constructor( private readonly params: DataMap, @@ -55,6 +59,8 @@ export class Connection implements StatementExecutor { private readonly configuration: Configuration = new Configuration(), ) { this.autoCommit = this.configuration.getAutoCommit(); + this.schemaManagerFactory = + this.configuration.getSchemaManagerFactory() ?? new DefaultSchemaManagerFactory(); } public getParams(): DataMap { @@ -484,6 +490,10 @@ export class Connection implements StatementExecutor { } } + public createSchemaManager(): AbstractSchemaManager { + return this.schemaManagerFactory.createSchemaManager(this); + } + private getNestedTransactionSavePointName(level: number): string { return `DATAZEN_${level}`; } diff --git a/src/driver/index.ts b/src/driver/index.ts new file mode 100644 index 0000000..1f4c3f3 --- /dev/null +++ b/src/driver/index.ts @@ -0,0 +1,29 @@ +export type { + Driver, + DriverConnection, + DriverExecutionResult, + DriverMiddleware, + DriverQueryResult, +} from "../driver"; +export { ParameterBindingStyle } from "../driver"; +export { ExceptionConverter as MySQLExceptionConverter } from "./api/mysql/exception-converter"; +export { ExceptionConverter as SQLSrvExceptionConverter } from "./api/sqlsrv/exception-converter"; +export type { ExceptionConverter, ExceptionConverterContext } from "./exception-converter"; +export { MSSQLConnection } from "./mssql/connection"; +export { MSSQLDriver } from "./mssql/driver"; +export { MSSQLExceptionConverter } from "./mssql/exception-converter"; +export type { + MSSQLConnectionParams, + MSSQLPoolLike, + MSSQLRequestLike, + MSSQLTransactionLike, +} from "./mssql/types"; +export { MySQL2Connection } from "./mysql2/connection"; +export { MySQL2Driver } from "./mysql2/driver"; +export { MySQL2ExceptionConverter } from "./mysql2/exception-converter"; +export type { + MySQL2ConnectionLike, + MySQL2ConnectionParams, + MySQL2ExecutorLike, + MySQL2PoolLike, +} from "./mysql2/types"; diff --git a/src/index.ts b/src/index.ts index c6ccbff..0dcf600 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,60 +10,15 @@ export type { DriverQueryResult, } from "./driver"; export { ParameterBindingStyle } from "./driver"; -export type { - ExceptionConverter, - ExceptionConverterContext, -} from "./driver/api/exception-converter"; -export { MSSQLDriver } from "./driver/mssql/driver"; -export { MySQL2Driver } from "./driver/mysql2/driver"; export { DriverManager } from "./driver-manager"; -export { - ConnectionException, - ConstraintViolationException, - DbalException, - DeadlockException, - DriverException, - DriverRequiredException, - ForeignKeyConstraintViolationException, - InvalidParameterException, - MalformedDsnException, - MissingNamedParameterException, - MissingPositionalParameterException, - MixedParameterStyleException, - NestedTransactionsNotSupportedException, - NoActiveTransactionException, - NoKeyValueException, - NotNullConstraintViolationException, - RollbackOnlyException, - SqlSyntaxException, - UniqueConstraintViolationException, - UnknownDriverException, -} from "./exception/index"; +export type { Exception } from "./exception"; export { ExpandArrayParameters } from "./expand-array-parameters"; -export type { LockMode } from "./lock-mode"; -export * as Logging from "./logging/index"; +export { LockMode } from "./lock-mode"; export { ParameterType } from "./parameter-type"; -export { - AbstractMySQLPlatform, - AbstractPlatform, - DB2Platform, - DateIntervalUnit, - MySQLPlatform, - OraclePlatform, - SQLServerPlatform, - TrimMode, -} from "./platforms"; -export * as Portability from "./portability/index"; export { Query } from "./query"; -export { ConflictResolutionMode } from "./query/for-update"; -export { PlaceHolder, QueryBuilder } from "./query/query-builder"; -export { UnionType } from "./query/union-type"; export { Result } from "./result"; export type { ServerVersionProvider } from "./server-version-provider"; -export type { SQLParser, Visitor as SQLParserVisitor } from "./sql/parser"; -export { Parser, ParserException, RegularExpressionException } from "./sql/parser"; export { Statement } from "./statement"; -export { DsnParser } from "./tools/dsn-parser"; export { TransactionIsolationLevel } from "./transaction-isolation-level"; export type { CompiledQuery, @@ -72,51 +27,3 @@ export type { QueryParameters, QueryScalarParameterType, } from "./types"; -export { - AsciiStringType, - BigIntType, - BinaryType, - BlobType, - BooleanType, - ConversionException, - DateImmutableType, - DateIntervalType, - DateTimeImmutableType, - DateTimeType, - DateTimeTzImmutableType, - DateTimeTzType, - DateType, - DecimalType, - EnumType, - FloatType, - GuidType, - IntegerType, - InvalidFormat, - InvalidType, - JsonObjectType, - JsonType, - JsonbObjectType, - JsonbType, - NumberType, - SerializationFailed, - SimpleArrayType, - SmallFloatType, - SmallIntType, - StringType, - TextType, - TimeImmutableType, - TimeType, - Type, - TypeAlreadyRegistered, - TypeArgumentCountException, - TypeNotFound, - TypeNotRegistered, - TypeRegistry, - Types, - TypesAlreadyExists, - TypesException, - UnknownColumnType, - ValueNotConvertible, - VarDateTimeImmutableType, - VarDateTimeType, -} from "./types/index"; diff --git a/src/lock-mode.ts b/src/lock-mode.ts index e51df7b..1f0d6c1 100644 --- a/src/lock-mode.ts +++ b/src/lock-mode.ts @@ -1 +1,6 @@ -export type LockMode = "none" | "optimistic" | "pessimistic_read" | "pessimistic_write"; +export enum LockMode { + NONE = "none", + OPTIMISTIC = "optimistic", + PESSIMISTIC_READ = "pessimistic_read", + PESSIMISTIC_WRITE = "pessimistic_write", +} diff --git a/src/platforms/abstract-mysql-platform.ts b/src/platforms/abstract-mysql-platform.ts index c4dff3b..9561dd1 100644 --- a/src/platforms/abstract-mysql-platform.ts +++ b/src/platforms/abstract-mysql-platform.ts @@ -1,9 +1,13 @@ +import type { Connection } from "../connection"; +import { MySQLSchemaManager } from "../schema/mysql-schema-manager"; import { DefaultSelectSQLBuilder } from "../sql/builder/default-select-sql-builder"; import { SelectSQLBuilder } from "../sql/builder/select-sql-builder"; import { TransactionIsolationLevel } from "../transaction-isolation-level"; import { Types } from "../types/types"; import { AbstractPlatform } from "./abstract-platform"; import { DateIntervalUnit } from "./date-interval-unit"; +import type { KeywordList } from "./keywords/keyword-list"; +import { MySQLKeywords } from "./keywords/mysql-keywords"; export abstract class AbstractMySQLPlatform extends AbstractPlatform { protected initializeDatazenTypeMappings(): Record { @@ -128,6 +132,14 @@ export abstract class AbstractMySQLPlatform extends AbstractPlatform { return true; } + protected createReservedKeywordsList(): KeywordList { + return new MySQLKeywords(); + } + + public createSchemaManager(connection: Connection): MySQLSchemaManager { + return new MySQLSchemaManager(connection, this); + } + public createSelectSQLBuilder(): SelectSQLBuilder { return new DefaultSelectSQLBuilder(this, "FOR UPDATE", null); } diff --git a/src/platforms/abstract-platform.ts b/src/platforms/abstract-platform.ts index 0360f7c..3050e1f 100644 --- a/src/platforms/abstract-platform.ts +++ b/src/platforms/abstract-platform.ts @@ -1,4 +1,7 @@ +import type { Connection } from "../connection"; import { LockMode } from "../lock-mode"; +import type { AbstractSchemaManager } from "../schema/abstract-schema-manager"; +import { UnquotedIdentifierFolding } from "../schema/name/unquoted-identifier-folding"; import { DefaultSelectSQLBuilder } from "../sql/builder/default-select-sql-builder"; import { DefaultUnionSQLBuilder } from "../sql/builder/default-union-sql-builder"; import { SelectSQLBuilder } from "../sql/builder/select-sql-builder"; @@ -7,10 +10,12 @@ import { WithSQLBuilder } from "../sql/builder/with-sql-builder"; import { TransactionIsolationLevel } from "../transaction-isolation-level"; import { DateIntervalUnit } from "./date-interval-unit"; import { NotSupported } from "./exception/not-supported"; +import { EmptyKeywords, KeywordList } from "./keywords"; import { TrimMode } from "./trim-mode"; export abstract class AbstractPlatform { private datazenTypeMapping: Record | null = null; + private reservedKeywords: KeywordList | null = null; /** * Quotes an identifier preserving dotted qualification. @@ -29,6 +34,10 @@ export abstract class AbstractPlatform { return `"${str.replace(/"/g, `""`)}"`; } + public getUnquotedIdentifierFolding(): UnquotedIdentifierFolding { + return UnquotedIdentifierFolding.NONE; + } + /** * Adds a driver-specific LIMIT clause to the query. */ @@ -450,6 +459,15 @@ export abstract class AbstractPlatform { return `ROLLBACK TO SAVEPOINT ${savepoint}`; } + public getReservedKeywordsList(): KeywordList { + this.reservedKeywords ??= this.createReservedKeywordsList(); + return this.reservedKeywords; + } + + protected createReservedKeywordsList(): KeywordList { + return new EmptyKeywords(); + } + public getDummySelectSQL(expression = "1"): string { return `SELECT ${expression}`; } @@ -551,6 +569,10 @@ export abstract class AbstractPlatform { return new WithSQLBuilder(); } + public createSchemaManager(_connection: Connection): AbstractSchemaManager { + throw NotSupported.new("createSchemaManager"); + } + private readLength(column: Record): number | undefined { return this.readNumber(column, "length"); } diff --git a/src/platforms/db2-platform.ts b/src/platforms/db2-platform.ts index 7832724..ddb8951 100644 --- a/src/platforms/db2-platform.ts +++ b/src/platforms/db2-platform.ts @@ -1,3 +1,5 @@ +import type { Connection } from "../connection"; +import { DB2SchemaManager } from "../schema/db2-schema-manager"; import { DefaultSelectSQLBuilder } from "../sql/builder/default-select-sql-builder"; import { SelectSQLBuilder } from "../sql/builder/select-sql-builder"; import { TransactionIsolationLevel } from "../transaction-isolation-level"; @@ -5,6 +7,8 @@ import { Types } from "../types/types"; import { AbstractPlatform } from "./abstract-platform"; import { DateIntervalUnit } from "./date-interval-unit"; import { NotSupported } from "./exception/not-supported"; +import { DB2Keywords } from "./keywords/db2-keywords"; +import type { KeywordList } from "./keywords/keyword-list"; export class DB2Platform extends AbstractPlatform { protected initializeDatazenTypeMappings(): Record { @@ -107,6 +111,14 @@ export class DB2Platform extends AbstractPlatform { return false; } + protected createReservedKeywordsList(): KeywordList { + return new DB2Keywords(); + } + + public createSchemaManager(connection: Connection): DB2SchemaManager { + return new DB2SchemaManager(connection, this); + } + public getDummySelectSQL(expression = "1"): string { return `SELECT ${expression} FROM sysibm.sysdummy1`; } diff --git a/src/platforms/index.ts b/src/platforms/index.ts index 48e3e99..8505271 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -2,6 +2,20 @@ export { AbstractMySQLPlatform } from "./abstract-mysql-platform"; export { AbstractPlatform } from "./abstract-platform"; export { DateIntervalUnit } from "./date-interval-unit"; export { DB2Platform } from "./db2-platform"; +export { + DB2Keywords, + EmptyKeywords, + KeywordList, + MariaDB117Keywords, + MariaDBKeywords, + MySQL80Keywords, + MySQL84Keywords, + MySQLKeywords, + OracleKeywords, + PostgreSQLKeywords, + SQLServerKeywords, + SQLiteKeywords, +} from "./keywords"; export { MySQLPlatform } from "./mysql-platform"; export { OraclePlatform } from "./oracle-platform"; export { SQLServerPlatform } from "./sql-server-platform"; diff --git a/src/platforms/keywords/db2-keywords.ts b/src/platforms/keywords/db2-keywords.ts new file mode 100644 index 0000000..4320ac3 --- /dev/null +++ b/src/platforms/keywords/db2-keywords.ts @@ -0,0 +1,402 @@ +import { KeywordList } from "./keyword-list"; + +export class DB2Keywords extends KeywordList { + protected getKeywords(): readonly string[] { + return [ + "ACTIVATE", + "ADD", + "AFTER", + "ALIAS", + "ALL", + "ALLOCATE", + "ALLOW", + "ALTER", + "AND", + "ANY", + "AS", + "ASENSITIVE", + "ASSOCIATE", + "ASUTIME", + "AT", + "ATTRIBUTES", + "AUDIT", + "AUTHORIZATION", + "AUX", + "AUXILIARY", + "BEFORE", + "BEGIN", + "BETWEEN", + "BINARY", + "BUFFERPOOL", + "BY", + "CACHE", + "CALL", + "CALLED", + "CAPTURE", + "CARDINALITY", + "CASCADED", + "CASE", + "CAST", + "CCSID", + "CHAR", + "CHARACTER", + "CHECK", + "CLONE", + "CLOSE", + "CLUSTER", + "COLLECTION", + "COLLID", + "COLUMN", + "COMMENT", + "COMMIT", + "CONCAT", + "CONDITION", + "CONNECT", + "CONNECTION", + "CONSTRAINT", + "CONTAINS", + "CONTINUE", + "COUNT", + "COUNT_BIG", + "CREATE", + "CROSS", + "CURRENT", + "CURRENT_DATE", + "CURRENT_LC_CTYPE", + "CURRENT_PATH", + "CURRENT_SCHEMA", + "CURRENT_SERVER", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_TIMEZONE", + "CURRENT_USER", + "CURSOR", + "CYCLE", + "DATA", + "DATABASE", + "DATAPARTITIONNAME", + "DATAPARTITIONNUM", + "DATE", + "DAY", + "DAYS", + "DB2GENERAL", + "DB2GENRL", + "DB2SQL", + "DBINFO", + "DBPARTITIONNAME", + "DBPARTITIONNUM", + "DEALLOCATE", + "DECLARE", + "DEFAULT", + "DEFAULTS", + "DEFINITION", + "DELETE", + "DENSE_RANK", + "DENSERANK", + "DESCRIBE", + "DESCRIPTOR", + "DETERMINISTIC", + "DIAGNOSTICS", + "DISABLE", + "DISALLOW", + "DISCONNECT", + "DISTINCT", + "DO", + "DOCUMENT", + "DOUBLE", + "DROP", + "DSSIZE", + "DYNAMIC", + "EACH", + "EDITPROC", + "ELSE", + "ELSEIF", + "ENABLE", + "ENCODING", + "ENCRYPTION", + "END", + "END-EXEC", + "ENDING", + "ERASE", + "ESCAPE", + "EVERY", + "EXCEPT", + "EXCEPTION", + "EXCLUDING", + "EXCLUSIVE", + "EXECUTE", + "EXISTS", + "EXIT", + "EXPLAIN", + "EXTERNAL", + "EXTRACT", + "FENCED", + "FETCH", + "FIELDPROC", + "FILE", + "FINAL", + "FOR", + "FOREIGN", + "FREE", + "FROM", + "FULL", + "FUNCTION", + "GENERAL", + "GENERATED", + "GET", + "GLOBAL", + "GO", + "GOTO", + "GRANT", + "GRAPHIC", + "GROUP", + "HANDLER", + "HASH", + "HASHED_VALUE", + "HAVING", + "HINT", + "HOLD", + "HOUR", + "HOURS", + "IDENTITY", + "IF", + "IMMEDIATE", + "IN", + "INCLUDING", + "INCLUSIVE", + "INCREMENT", + "INDEX", + "INDICATOR", + "INF", + "INFINITY", + "INHERIT", + "INNER", + "INOUT", + "INSENSITIVE", + "INSERT", + "INTEGRITY", + "INTERSECT", + "INTO", + "IS", + "ISOBID", + "ISOLATION", + "ITERATE", + "JAR", + "JAVA", + "JOIN", + "KEEP", + "KEY", + "LABEL", + "LANGUAGE", + "LATERAL", + "LC_CTYPE", + "LEAVE", + "LEFT", + "LIKE", + "LINKTYPE", + "LOCAL", + "LOCALDATE", + "LOCALE", + "LOCALTIME", + "LOCALTIMESTAMP RIGHT", + "LOCATOR", + "LOCATORS", + "LOCK", + "LOCKMAX", + "LOCKSIZE", + "LONG", + "LOOP", + "MAINTAINED", + "MATERIALIZED", + "MAXVALUE", + "MICROSECOND", + "MICROSECONDS", + "MINUTE", + "MINUTES", + "MINVALUE", + "MODE", + "MODIFIES", + "MONTH", + "MONTHS", + "NAN", + "NEW", + "NEW_TABLE", + "NEXTVAL", + "NO", + "NOCACHE", + "NOCYCLE", + "NODENAME", + "NODENUMBER", + "NOMAXVALUE", + "NOMINVALUE", + "NONE", + "NOORDER", + "NORMALIZED", + "NOT", + "NULL", + "NULLS", + "NUMPARTS", + "OBID", + "OF", + "OLD", + "OLD_TABLE", + "ON", + "OPEN", + "OPTIMIZATION", + "OPTIMIZE", + "OPTION", + "OR", + "ORDER", + "OUT", + "OUTER", + "OVER", + "OVERRIDING", + "PACKAGE", + "PADDED", + "PAGESIZE", + "PARAMETER", + "PART", + "PARTITION", + "PARTITIONED", + "PARTITIONING", + "PARTITIONS", + "PASSWORD", + "PATH", + "PIECESIZE", + "PLAN", + "POSITION", + "PRECISION", + "PREPARE", + "PREVVAL", + "PRIMARY", + "PRIQTY", + "PRIVILEGES", + "PROCEDURE", + "PROGRAM", + "PSID", + "PUBLIC", + "QUERY", + "QUERYNO", + "RANGE", + "RANK", + "READ", + "READS", + "RECOVERY", + "REFERENCES", + "REFERENCING", + "REFRESH", + "RELEASE", + "RENAME", + "REPEAT", + "RESET", + "RESIGNAL", + "RESTART", + "RESTRICT", + "RESULT", + "RESULT_SET_LOCATOR WLM", + "RETURN", + "RETURNS", + "REVOKE", + "ROLE", + "ROLLBACK", + "ROUND_CEILING", + "ROUND_DOWN", + "ROUND_FLOOR", + "ROUND_HALF_DOWN", + "ROUND_HALF_EVEN", + "ROUND_HALF_UP", + "ROUND_UP", + "ROUTINE", + "ROW", + "ROW_NUMBER", + "ROWNUMBER", + "ROWS", + "ROWSET", + "RRN", + "RUN", + "SAVEPOINT", + "SCHEMA", + "SCRATCHPAD", + "SCROLL", + "SEARCH", + "SECOND", + "SECONDS", + "SECQTY", + "SECURITY", + "SELECT", + "SENSITIVE", + "SEQUENCE", + "SESSION", + "SESSION_USER", + "SET", + "SIGNAL", + "SIMPLE", + "SNAN", + "SOME", + "SOURCE", + "SPECIFIC", + "SQL", + "SQLID", + "STACKED", + "STANDARD", + "START", + "STARTING", + "STATEMENT", + "STATIC", + "STATMENT", + "STAY", + "STOGROUP", + "STORES", + "STYLE", + "SUBSTRING", + "SUMMARY", + "SYNONYM", + "SYSFUN", + "SYSIBM", + "SYSPROC", + "SYSTEM", + "SYSTEM_USER", + "TABLE", + "TABLESPACE", + "THEN", + "TIME", + "TIMESTAMP", + "TO", + "TRANSACTION", + "TRIGGER", + "TRIM", + "TRUNCATE", + "TYPE", + "UNDO", + "UNION", + "UNIQUE", + "UNTIL", + "UPDATE", + "USAGE", + "USER", + "USING", + "VALIDPROC", + "VALUE", + "VALUES", + "VARIABLE", + "VARIANT", + "VCAT", + "VERSION", + "VIEW", + "VOLATILE", + "VOLUMES", + "WHEN", + "WHENEVER", + "WHERE", + "WHILE", + "WITH", + "WITHOUT", + "WRITE", + "XMLELEMENT", + "XMLEXISTS", + "XMLNAMESPACES", + "YEAR", + "YEARS", + ]; + } +} diff --git a/src/platforms/keywords/empty-keywords.ts b/src/platforms/keywords/empty-keywords.ts new file mode 100644 index 0000000..53c0bec --- /dev/null +++ b/src/platforms/keywords/empty-keywords.ts @@ -0,0 +1,7 @@ +import { KeywordList } from "./keyword-list"; + +export class EmptyKeywords extends KeywordList { + protected getKeywords(): readonly string[] { + return []; + } +} diff --git a/src/platforms/keywords/index.ts b/src/platforms/keywords/index.ts new file mode 100644 index 0000000..93c605f --- /dev/null +++ b/src/platforms/keywords/index.ts @@ -0,0 +1,12 @@ +export { DB2Keywords } from "./db2-keywords"; +export { EmptyKeywords } from "./empty-keywords"; +export { KeywordList } from "./keyword-list"; +export { MariaDBKeywords } from "./mariadb-keywords"; +export { MariaDB117Keywords } from "./mariadb117-keywords"; +export { MySQLKeywords } from "./mysql-keywords"; +export { MySQL80Keywords } from "./mysql80-keywords"; +export { MySQL84Keywords } from "./mysql84-keywords"; +export { OracleKeywords } from "./oracle-keywords"; +export { PostgreSQLKeywords } from "./postgresql-keywords"; +export { SQLServerKeywords } from "./sql-server-keywords"; +export { SQLiteKeywords } from "./sqlite-keywords"; diff --git a/src/platforms/keywords/keyword-list.ts b/src/platforms/keywords/keyword-list.ts new file mode 100644 index 0000000..4983667 --- /dev/null +++ b/src/platforms/keywords/keyword-list.ts @@ -0,0 +1,22 @@ +export abstract class KeywordList { + private keywords: Set | null = null; + + public isKeyword(word: string): boolean { + if (this.keywords === null) { + this.initializeKeywords(); + } + + const keywords = this.keywords; + if (keywords === null) { + return false; + } + + return keywords.has(word.toUpperCase()); + } + + protected initializeKeywords(): void { + this.keywords = new Set(this.getKeywords().map((keyword) => keyword.toUpperCase())); + } + + protected abstract getKeywords(): readonly string[]; +} diff --git a/src/platforms/keywords/mariadb-keywords.ts b/src/platforms/keywords/mariadb-keywords.ts new file mode 100644 index 0000000..0110a48 --- /dev/null +++ b/src/platforms/keywords/mariadb-keywords.ts @@ -0,0 +1,255 @@ +import { KeywordList } from "./keyword-list"; + +export class MariaDBKeywords extends KeywordList { + protected getKeywords(): readonly string[] { + return [ + "ACCESSIBLE", + "ADD", + "ALL", + "ALTER", + "ANALYZE", + "AND", + "AS", + "ASC", + "ASENSITIVE", + "BEFORE", + "BETWEEN", + "BIGINT", + "BINARY", + "BLOB", + "BOTH", + "BY", + "CALL", + "CASCADE", + "CASE", + "CHANGE", + "CHAR", + "CHARACTER", + "CHECK", + "COLLATE", + "COLUMN", + "CONDITION", + "CONSTRAINT", + "CONTINUE", + "CONVERT", + "CREATE", + "CROSS", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "CURSOR", + "DATABASE", + "DATABASES", + "DAY_HOUR", + "DAY_MICROSECOND", + "DAY_MINUTE", + "DAY_SECOND", + "DEC", + "DECIMAL", + "DECLARE", + "DEFAULT", + "DELAYED", + "DELETE", + "DESC", + "DESCRIBE", + "DETERMINISTIC", + "DISTINCT", + "DISTINCTROW", + "DIV", + "DOUBLE", + "DROP", + "DUAL", + "EACH", + "ELSE", + "ELSEIF", + "ENCLOSED", + "ESCAPED", + "EXCEPT", + "EXISTS", + "EXIT", + "EXPLAIN", + "FALSE", + "FETCH", + "FLOAT", + "FLOAT4", + "FLOAT8", + "FOR", + "FORCE", + "FOREIGN", + "FROM", + "FULLTEXT", + "GENERATED", + "GET", + "GENERAL", + "GRANT", + "GROUP", + "HAVING", + "HIGH_PRIORITY", + "HOUR_MICROSECOND", + "HOUR_MINUTE", + "HOUR_SECOND", + "IF", + "IGNORE", + "IGNORE_SERVER_IDS", + "IN", + "INDEX", + "INFILE", + "INNER", + "INOUT", + "INSENSITIVE", + "INSERT", + "INT", + "INT1", + "INT2", + "INT3", + "INT4", + "INT8", + "INTEGER", + "INTERSECT", + "INTERVAL", + "INTO", + "IO_AFTER_GTIDS", + "IO_BEFORE_GTIDS", + "IS", + "ITERATE", + "JOIN", + "KEY", + "KEYS", + "KILL", + "LEADING", + "LEAVE", + "LEFT", + "LIKE", + "LIMIT", + "LINEAR", + "LINES", + "LOAD", + "LOCALTIME", + "LOCALTIMESTAMP", + "LOCK", + "LONG", + "LONGBLOB", + "LONGTEXT", + "LOOP", + "LOW_PRIORITY", + "MASTER_BIND", + "MASTER_HEARTBEAT_PERIOD", + "MASTER_SSL_VERIFY_SERVER_CERT", + "MATCH", + "MAXVALUE", + "MEDIUMBLOB", + "MEDIUMINT", + "MEDIUMTEXT", + "MIDDLEINT", + "MINUTE_MICROSECOND", + "MINUTE_SECOND", + "MOD", + "MODIFIES", + "NATURAL", + "NO_WRITE_TO_BINLOG", + "NOT", + "NULL", + "NUMERIC", + "OFFSET", + "ON", + "OPTIMIZE", + "OPTIMIZER_COSTS", + "OPTION", + "OPTIONALLY", + "OR", + "ORDER", + "OUT", + "OUTER", + "OUTFILE", + "OVER", + "PARTITION", + "PRECISION", + "PRIMARY", + "PROCEDURE", + "PURGE", + "RANGE", + "READ", + "READ_WRITE", + "READS", + "REAL", + "RECURSIVE", + "REFERENCES", + "REGEXP", + "RELEASE", + "RENAME", + "REPEAT", + "REPLACE", + "REQUIRE", + "RESIGNAL", + "RESTRICT", + "RETURN", + "RETURNING", + "REVOKE", + "RIGHT", + "RLIKE", + "ROWS", + "SCHEMA", + "SCHEMAS", + "SECOND_MICROSECOND", + "SELECT", + "SENSITIVE", + "SEPARATOR", + "SET", + "SHOW", + "SIGNAL", + "SLOW", + "SMALLINT", + "SPATIAL", + "SPECIFIC", + "SQL", + "SQL_BIG_RESULT", + "SQL_CALC_FOUND_ROWS", + "SQL_SMALL_RESULT", + "SQLEXCEPTION", + "SQLSTATE", + "SQLWARNING", + "SSL", + "STARTING", + "STORED", + "STRAIGHT_JOIN", + "TABLE", + "TERMINATED", + "THEN", + "TINYBLOB", + "TINYINT", + "TINYTEXT", + "TO", + "TRAILING", + "TRIGGER", + "TRUE", + "UNDO", + "UNION", + "UNIQUE", + "UNLOCK", + "UNSIGNED", + "UPDATE", + "USAGE", + "USE", + "USING", + "UTC_DATE", + "UTC_TIME", + "UTC_TIMESTAMP", + "VALUES", + "VARBINARY", + "VARCHAR", + "VARCHARACTER", + "VARYING", + "VIRTUAL", + "WHEN", + "WHERE", + "WHILE", + "WINDOW", + "WITH", + "WRITE", + "XOR", + "YEAR_MONTH", + "ZEROFILL", + ]; + } +} diff --git a/src/platforms/keywords/mariadb117-keywords.ts b/src/platforms/keywords/mariadb117-keywords.ts new file mode 100644 index 0000000..60d3c40 --- /dev/null +++ b/src/platforms/keywords/mariadb117-keywords.ts @@ -0,0 +1,7 @@ +import { KeywordList } from "./keyword-list"; + +export class MariaDB117Keywords extends KeywordList { + protected getKeywords(): readonly string[] { + return []; + } +} diff --git a/src/platforms/keywords/mysql-keywords.ts b/src/platforms/keywords/mysql-keywords.ts new file mode 100644 index 0000000..452810f --- /dev/null +++ b/src/platforms/keywords/mysql-keywords.ts @@ -0,0 +1,243 @@ +import { KeywordList } from "./keyword-list"; + +export class MySQLKeywords extends KeywordList { + protected getKeywords(): readonly string[] { + return [ + "ACCESSIBLE", + "ADD", + "ALL", + "ALTER", + "ANALYZE", + "AND", + "AS", + "ASC", + "ASENSITIVE", + "BEFORE", + "BETWEEN", + "BIGINT", + "BINARY", + "BLOB", + "BOTH", + "BY", + "CALL", + "CASCADE", + "CASE", + "CHANGE", + "CHAR", + "CHARACTER", + "CHECK", + "COLLATE", + "COLUMN", + "CONDITION", + "CONSTRAINT", + "CONTINUE", + "CONVERT", + "CREATE", + "CROSS", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "CURSOR", + "DATABASE", + "DATABASES", + "DAY_HOUR", + "DAY_MICROSECOND", + "DAY_MINUTE", + "DAY_SECOND", + "DEC", + "DECIMAL", + "DECLARE", + "DEFAULT", + "DELAYED", + "DELETE", + "DESC", + "DESCRIBE", + "DETERMINISTIC", + "DISTINCT", + "DISTINCTROW", + "DIV", + "DOUBLE", + "DROP", + "DUAL", + "EACH", + "ELSE", + "ELSEIF", + "ENCLOSED", + "ESCAPED", + "EXISTS", + "EXIT", + "EXPLAIN", + "FALSE", + "FETCH", + "FLOAT", + "FLOAT4", + "FLOAT8", + "FOR", + "FORCE", + "FOREIGN", + "FROM", + "FULLTEXT", + "GENERATED", + "GET", + "GRANT", + "GROUP", + "HAVING", + "HIGH_PRIORITY", + "HOUR_MICROSECOND", + "HOUR_MINUTE", + "HOUR_SECOND", + "IF", + "IGNORE", + "IN", + "INDEX", + "INFILE", + "INNER", + "INOUT", + "INSENSITIVE", + "INSERT", + "INT", + "INT1", + "INT2", + "INT3", + "INT4", + "INT8", + "INTEGER", + "INTERVAL", + "INTO", + "IO_AFTER_GTIDS", + "IO_BEFORE_GTIDS", + "IS", + "ITERATE", + "JOIN", + "KEY", + "KEYS", + "KILL", + "LEADING", + "LEAVE", + "LEFT", + "LIKE", + "LIMIT", + "LINEAR", + "LINES", + "LOAD", + "LOCALTIME", + "LOCALTIMESTAMP", + "LOCK", + "LONG", + "LONGBLOB", + "LONGTEXT", + "LOOP", + "LOW_PRIORITY", + "MASTER_BIND", + "MASTER_SSL_VERIFY_SERVER_CERT", + "MATCH", + "MAXVALUE", + "MEDIUMBLOB", + "MEDIUMINT", + "MEDIUMTEXT", + "MIDDLEINT", + "MINUTE_MICROSECOND", + "MINUTE_SECOND", + "MOD", + "MODIFIES", + "NATURAL", + "NO_WRITE_TO_BINLOG", + "NOT", + "NULL", + "NUMERIC", + "ON", + "OPTIMIZE", + "OPTIMIZER_COSTS", + "OPTION", + "OPTIONALLY", + "OR", + "ORDER", + "OUT", + "OUTER", + "OUTFILE", + "PARTITION", + "PRECISION", + "PRIMARY", + "PROCEDURE", + "PURGE", + "RANGE", + "READ", + "READ_WRITE", + "READS", + "REAL", + "REFERENCES", + "REGEXP", + "RELEASE", + "RENAME", + "REPEAT", + "REPLACE", + "REQUIRE", + "RESIGNAL", + "RESTRICT", + "RETURN", + "REVOKE", + "RIGHT", + "RLIKE", + "SCHEMA", + "SCHEMAS", + "SECOND_MICROSECOND", + "SELECT", + "SENSITIVE", + "SEPARATOR", + "SET", + "SHOW", + "SIGNAL", + "SMALLINT", + "SPATIAL", + "SPECIFIC", + "SQL", + "SQL_BIG_RESULT", + "SQL_CALC_FOUND_ROWS", + "SQL_SMALL_RESULT", + "SQLEXCEPTION", + "SQLSTATE", + "SQLWARNING", + "SSL", + "STARTING", + "STORED", + "STRAIGHT_JOIN", + "TABLE", + "TERMINATED", + "THEN", + "TINYBLOB", + "TINYINT", + "TINYTEXT", + "TO", + "TRAILING", + "TRIGGER", + "TRUE", + "UNDO", + "UNION", + "UNIQUE", + "UNLOCK", + "UNSIGNED", + "UPDATE", + "USAGE", + "USE", + "USING", + "UTC_DATE", + "UTC_TIME", + "UTC_TIMESTAMP", + "VALUES", + "VARBINARY", + "VARCHAR", + "VARCHARACTER", + "VARYING", + "VIRTUAL", + "WHEN", + "WHERE", + "WHILE", + "WITH", + "WRITE", + "XOR", + "YEAR_MONTH", + "ZEROFILL", + ]; + } +} diff --git a/src/platforms/keywords/mysql80-keywords.ts b/src/platforms/keywords/mysql80-keywords.ts new file mode 100644 index 0000000..fd6975f --- /dev/null +++ b/src/platforms/keywords/mysql80-keywords.ts @@ -0,0 +1,7 @@ +import { KeywordList } from "./keyword-list"; + +export class MySQL80Keywords extends KeywordList { + protected getKeywords(): readonly string[] { + return []; + } +} diff --git a/src/platforms/keywords/mysql84-keywords.ts b/src/platforms/keywords/mysql84-keywords.ts new file mode 100644 index 0000000..53fa362 --- /dev/null +++ b/src/platforms/keywords/mysql84-keywords.ts @@ -0,0 +1,7 @@ +import { KeywordList } from "./keyword-list"; + +export class MySQL84Keywords extends KeywordList { + protected getKeywords(): readonly string[] { + return []; + } +} diff --git a/src/platforms/keywords/oracle-keywords.ts b/src/platforms/keywords/oracle-keywords.ts new file mode 100644 index 0000000..fd82d06 --- /dev/null +++ b/src/platforms/keywords/oracle-keywords.ts @@ -0,0 +1,121 @@ +import { KeywordList } from "./keyword-list"; + +export class OracleKeywords extends KeywordList { + protected getKeywords(): readonly string[] { + return [ + "ACCESS", + "ADD", + "ALL", + "ALTER", + "AND", + "ANY", + "ARRAYLEN", + "AS", + "ASC", + "AUDIT", + "BETWEEN", + "BY", + "CHAR", + "CHECK", + "CLUSTER", + "COLUMN", + "COMMENT", + "COMPRESS", + "CONNECT", + "CREATE", + "CURRENT", + "DATE", + "DECIMAL", + "DEFAULT", + "DELETE", + "DESC", + "DISTINCT", + "DROP", + "ELSE", + "EXCLUSIVE", + "EXISTS", + "FILE", + "FLOAT", + "FOR", + "FROM", + "GRANT", + "GROUP", + "HAVING", + "IDENTIFIED", + "IMMEDIATE", + "IN", + "INCREMENT", + "INDEX", + "INITIAL", + "INSERT", + "INTEGER", + "INTERSECT", + "INTO", + "IS", + "LEVEL", + "LIKE", + "LOCK", + "LONG", + "MAXEXTENTS", + "MINUS", + "MODE", + "MODIFY", + "NOAUDIT", + "NOCOMPRESS", + "NOT", + "NOTFOUND", + "NOWAIT", + "NULL", + "NUMBER", + "OF", + "OFFLINE", + "ON", + "ONLINE", + "OPTION", + "OR", + "ORDER", + "PCTFREE", + "PRIOR", + "PRIVILEGES", + "PUBLIC", + "RANGE", + "RAW", + "RENAME", + "RESOURCE", + "REVOKE", + "ROW", + "ROWID", + "ROWLABEL", + "ROWNUM", + "ROWS", + "SELECT", + "SESSION", + "SET", + "SHARE", + "SIZE", + "SMALLINT", + "SQLBUF", + "START", + "SUCCESSFUL", + "SYNONYM", + "SYSDATE", + "TABLE", + "THEN", + "TO", + "TRIGGER", + "UID", + "UNION", + "UNIQUE", + "UPDATE", + "USER", + "VALIDATE", + "VALUES", + "VARCHAR", + "VARCHAR2", + "VIEW", + "WHENEVER", + "WHERE", + "WITH", + ]; + } +} diff --git a/src/platforms/keywords/postgresql-keywords.ts b/src/platforms/keywords/postgresql-keywords.ts new file mode 100644 index 0000000..cb4b377 --- /dev/null +++ b/src/platforms/keywords/postgresql-keywords.ts @@ -0,0 +1,107 @@ +import { KeywordList } from "./keyword-list"; + +export class PostgreSQLKeywords extends KeywordList { + protected getKeywords(): readonly string[] { + return [ + "ALL", + "ANALYSE", + "ANALYZE", + "AND", + "ANY", + "ARRAY", + "AS", + "ASC", + "ASYMMETRIC", + "AUTHORIZATION", + "BINARY", + "BOTH", + "CASE", + "CAST", + "CHECK", + "COLLATE", + "COLLATION", + "COLUMN", + "CONCURRENTLY", + "CONSTRAINT", + "CREATE", + "CROSS", + "CURRENT_CATALOG", + "CURRENT_DATE", + "CURRENT_ROLE", + "CURRENT_SCHEMA", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "DEFAULT", + "DEFERRABLE", + "DESC", + "DISTINCT", + "DO", + "ELSE", + "END", + "EXCEPT", + "FALSE", + "FETCH", + "FOR", + "FOREIGN", + "FREEZE", + "FROM", + "FULL", + "GRANT", + "GROUP", + "HAVING", + "ILIKE", + "IN", + "INITIALLY", + "INNER", + "INTERSECT", + "INTO", + "IS", + "ISNULL", + "JOIN", + "LATERAL", + "LEADING", + "LEFT", + "LIKE", + "LIMIT", + "LOCALTIME", + "LOCALTIMESTAMP", + "NATURAL", + "NOT", + "NOTNULL", + "NULL", + "OFFSET", + "ON", + "ONLY", + "OR", + "ORDER", + "OUTER", + "OVERLAPS", + "PLACING", + "PRIMARY", + "REFERENCES", + "RETURNING", + "RIGHT", + "SELECT", + "SESSION_USER", + "SIMILAR", + "SOME", + "SYMMETRIC", + "TABLE", + "THEN", + "TO", + "TRAILING", + "TRUE", + "UNION", + "UNIQUE", + "USER", + "USING", + "VARIADIC", + "VERBOSE", + "WHEN", + "WHERE", + "WINDOW", + "WITH", + ]; + } +} diff --git a/src/platforms/keywords/sql-server-keywords.ts b/src/platforms/keywords/sql-server-keywords.ts new file mode 100644 index 0000000..d08df80 --- /dev/null +++ b/src/platforms/keywords/sql-server-keywords.ts @@ -0,0 +1,193 @@ +import { KeywordList } from "./keyword-list"; + +export class SQLServerKeywords extends KeywordList { + protected getKeywords(): readonly string[] { + return [ + "ADD", + "ALL", + "ALTER", + "AND", + "ANY", + "AS", + "ASC", + "AUTHORIZATION", + "BACKUP", + "BEGIN", + "BETWEEN", + "BREAK", + "BROWSE", + "BULK", + "BY", + "CASCADE", + "CASE", + "CHECK", + "CHECKPOINT", + "CLOSE", + "CLUSTERED", + "COALESCE", + "COLLATE", + "COLUMN", + "COMMIT", + "COMPUTE", + "CONSTRAINT", + "CONTAINS", + "CONTAINSTABLE", + "CONTINUE", + "CONVERT", + "CREATE", + "CROSS", + "CURRENT", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "CURSOR", + "DATABASE", + "DBCC", + "DEALLOCATE", + "DECLARE", + "DEFAULT", + "DELETE", + "DENY", + "DESC", + "DISK", + "DISTINCT", + "DISTRIBUTED", + "DOUBLE", + "DROP", + "DUMP", + "ELSE", + "END", + "ERRLVL", + "ESCAPE", + "EXCEPT", + "EXEC", + "EXECUTE", + "EXISTS", + "EXIT", + "EXTERNAL", + "FETCH", + "FILE", + "FILLFACTOR", + "FOR", + "FOREIGN", + "FREETEXT", + "FREETEXTTABLE", + "FROM", + "FULL", + "FUNCTION", + "GOTO", + "GRANT", + "GROUP", + "HAVING", + "HOLDLOCK", + "IDENTITY", + "IDENTITY_INSERT", + "IDENTITYCOL", + "IF", + "IN", + "INDEX", + "INNER", + "INSERT", + "INTERSECT", + "INTO", + "IS", + "JOIN", + "KEY", + "KILL", + "LEFT", + "LIKE", + "LINENO", + "LOAD", + "MERGE", + "NATIONAL", + "NOCHECK ", + "NONCLUSTERED", + "NOT", + "NULL", + "NULLIF", + "OF", + "OFF", + "OFFSETS", + "ON", + "OPEN", + "OPENDATASOURCE", + "OPENQUERY", + "OPENROWSET", + "OPENXML", + "OPTION", + "OR", + "ORDER", + "OUTER", + "OVER", + "PERCENT", + "PIVOT", + "PLAN", + "PRECISION", + "PRIMARY", + "PRINT", + "PROC", + "PROCEDURE", + "PUBLIC", + "RAISERROR", + "READ", + "READTEXT", + "RECONFIGURE", + "REFERENCES", + "REPLICATION", + "RESTORE", + "RESTRICT", + "RETURN", + "REVERT", + "REVOKE", + "RIGHT", + "ROLLBACK", + "ROWCOUNT", + "ROWGUIDCOL", + "RULE", + "SAVE", + "SCHEMA", + "SECURITYAUDIT", + "SELECT", + "SEMANTICKEYPHRASETABLE", + "SEMANTICSIMILARITYDETAILSTABLE", + "SEMANTICSIMILARITYTABLE", + "SESSION_USER", + "SET", + "SETUSER", + "SHUTDOWN", + "SOME", + "STATISTICS", + "SYSTEM_USER", + "TABLE", + "TABLESAMPLE", + "TEXTSIZE", + "THEN", + "TO", + "TOP", + "TRAN", + "TRANSACTION", + "TRIGGER", + "TRUNCATE", + "TRY_CONVERT", + "TSEQUAL", + "UNION", + "UNIQUE", + "UNPIVOT", + "UPDATE", + "UPDATETEXT", + "USE", + "USER", + "VALUES", + "VARYING", + "VIEW", + "WAITFOR", + "WHEN", + "WHERE", + "WHILE", + "WITH", + "WITHIN GROUP", + "WRITETEXT", + ]; + } +} diff --git a/src/platforms/keywords/sqlite-keywords.ts b/src/platforms/keywords/sqlite-keywords.ts new file mode 100644 index 0000000..28f597d --- /dev/null +++ b/src/platforms/keywords/sqlite-keywords.ts @@ -0,0 +1,129 @@ +import { KeywordList } from "./keyword-list"; + +export class SQLiteKeywords extends KeywordList { + protected getKeywords(): readonly string[] { + return [ + "ABORT", + "ACTION", + "ADD", + "AFTER", + "ALL", + "ALTER", + "ANALYZE", + "AND", + "AS", + "ASC", + "ATTACH", + "AUTOINCREMENT", + "BEFORE", + "BEGIN", + "BETWEEN", + "BY", + "CASCADE", + "CASE", + "CAST", + "CHECK", + "COLLATE", + "COLUMN", + "COMMIT", + "CONFLICT", + "CONSTRAINT", + "CREATE", + "CROSS", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "DATABASE", + "DEFAULT", + "DEFERRABLE", + "DEFERRED", + "DELETE", + "DESC", + "DETACH", + "DISTINCT", + "DROP", + "EACH", + "ELSE", + "END", + "ESCAPE", + "EXCEPT", + "EXCLUSIVE", + "EXISTS", + "EXPLAIN", + "FAIL", + "FOR", + "FOREIGN", + "FROM", + "FULL", + "GLOB", + "GROUP", + "HAVING", + "IF", + "IGNORE", + "IMMEDIATE", + "IN", + "INDEX", + "INDEXED", + "INITIALLY", + "INNER", + "INSERT", + "INSTEAD", + "INTERSECT", + "INTO", + "IS", + "ISNULL", + "JOIN", + "KEY", + "LEFT", + "LIKE", + "LIMIT", + "MATCH", + "NATURAL", + "NO", + "NOT", + "NOTNULL", + "NULL", + "OF", + "OFFSET", + "ON", + "OR", + "ORDER", + "OUTER", + "PLAN", + "PRAGMA", + "PRIMARY", + "QUERY", + "RAISE", + "REFERENCES", + "REGEXP", + "REINDEX", + "RELEASE", + "RENAME", + "REPLACE", + "RESTRICT", + "RIGHT", + "ROLLBACK", + "ROW", + "SAVEPOINT", + "SELECT", + "SET", + "TABLE", + "TEMP", + "TEMPORARY", + "THEN", + "TO", + "TRANSACTION", + "TRIGGER", + "UNION", + "UNIQUE", + "UPDATE", + "USING", + "VACUUM", + "VALUES", + "VIEW", + "VIRTUAL", + "WHEN", + "WHERE", + ]; + } +} diff --git a/src/platforms/oracle-platform.ts b/src/platforms/oracle-platform.ts index 579e021..7544fb9 100644 --- a/src/platforms/oracle-platform.ts +++ b/src/platforms/oracle-platform.ts @@ -1,7 +1,11 @@ +import type { Connection } from "../connection"; +import { OracleSchemaManager } from "../schema/oracle-schema-manager"; import { TransactionIsolationLevel } from "../transaction-isolation-level"; import { Types } from "../types/types"; import { AbstractPlatform } from "./abstract-platform"; import { DateIntervalUnit } from "./date-interval-unit"; +import type { KeywordList } from "./keywords/keyword-list"; +import { OracleKeywords } from "./keywords/oracle-keywords"; export class OraclePlatform extends AbstractPlatform { protected initializeDatazenTypeMappings(): Record { @@ -112,6 +116,14 @@ export class OraclePlatform extends AbstractPlatform { return false; } + protected createReservedKeywordsList(): KeywordList { + return new OracleKeywords(); + } + + public createSchemaManager(connection: Connection): OracleSchemaManager { + return new OracleSchemaManager(connection, this); + } + public releaseSavePoint(_savepoint: string): string { return ""; } diff --git a/src/platforms/sql-server-platform.ts b/src/platforms/sql-server-platform.ts index 5ef4395..9fa0cc6 100644 --- a/src/platforms/sql-server-platform.ts +++ b/src/platforms/sql-server-platform.ts @@ -1,11 +1,17 @@ +import type { Connection } from "../connection"; import { LockMode } from "../lock-mode"; +import { SQLServerSchemaManager } from "../schema/sql-server-schema-manager"; import { TransactionIsolationLevel } from "../transaction-isolation-level"; import { Types } from "../types/types"; import { AbstractPlatform } from "./abstract-platform"; import { DateIntervalUnit } from "./date-interval-unit"; +import type { KeywordList } from "./keywords/keyword-list"; +import { SQLServerKeywords } from "./keywords/sql-server-keywords"; import { TrimMode } from "./trim-mode"; export class SQLServerPlatform extends AbstractPlatform { + public static readonly OPTION_DEFAULT_CONSTRAINT_NAME = "default_constraint_name"; + protected initializeDatazenTypeMappings(): Record { return { bigint: Types.BIGINT, @@ -85,6 +91,14 @@ export class SQLServerPlatform extends AbstractPlatform { return true; } + protected createReservedKeywordsList(): KeywordList { + return new SQLServerKeywords(); + } + + public createSchemaManager(connection: Connection): SQLServerSchemaManager { + return new SQLServerSchemaManager(connection, this); + } + public getLocateExpression( string: string, substring: string, @@ -196,12 +210,12 @@ export class SQLServerPlatform extends AbstractPlatform { public appendLockHint(fromClause: string, lockMode: LockMode): string { switch (lockMode) { - case "none": - case "optimistic": + case LockMode.NONE: + case LockMode.OPTIMISTIC: return fromClause; - case "pessimistic_read": + case LockMode.PESSIMISTIC_READ: return `${fromClause} WITH (HOLDLOCK, ROWLOCK)`; - case "pessimistic_write": + case LockMode.PESSIMISTIC_WRITE: return `${fromClause} WITH (UPDLOCK, ROWLOCK)`; } } diff --git a/src/query/index.ts b/src/query/index.ts new file mode 100644 index 0000000..4bc7e67 --- /dev/null +++ b/src/query/index.ts @@ -0,0 +1,17 @@ +export { Query } from "../query"; +export { CommonTableExpression } from "./common-table-expression"; +export { NonUniqueAlias } from "./exception/non-unique-alias"; +export { UnknownAlias } from "./exception/unknown-alias"; +export { CompositeExpression } from "./expression/composite-expression"; +export { ExpressionBuilder } from "./expression/expression-builder"; +export { ConflictResolutionMode, ForUpdate } from "./for-update"; +export { From } from "./from"; +export { Join } from "./join"; +export { Limit } from "./limit"; +export { PlaceHolder, QueryBuilder } from "./query-builder"; +export { QueryException } from "./query-exception"; +export { QueryType } from "./query-type"; +export { SelectQuery } from "./select-query"; +export { Union } from "./union"; +export { UnionQuery } from "./union-query"; +export { UnionType } from "./union-type"; diff --git a/src/schema/abstract-asset.ts b/src/schema/abstract-asset.ts new file mode 100644 index 0000000..cd50409 --- /dev/null +++ b/src/schema/abstract-asset.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; + +import type { AbstractPlatform } from "../platforms/abstract-platform"; + +/** + * Doctrine-inspired base class for schema assets (table, column, index, sequence...). + */ +export abstract class AbstractAsset { + protected _name = ""; + protected _namespace: string | null = null; + protected _quoted = false; + + constructor(name: string) { + this._setName(name); + } + + protected _setName(name: string): void { + this._quoted = this.isIdentifierQuoted(name); + const normalized = this._quoted ? this.trimQuotes(name) : name; + + const parts = normalized.split("."); + if (parts.length > 1) { + this._namespace = parts[0] ?? null; + this._name = parts.slice(1).join("."); + return; + } + + this._namespace = null; + this._name = normalized; + } + + public getName(): string { + if (this._namespace === null) { + return this._name; + } + + return `${this._namespace}.${this._name}`; + } + + public getNamespaceName(): string | null { + return this._namespace; + } + + public isInDefaultNamespace(defaultNamespaceName: string): boolean { + return this._namespace === null || this._namespace === defaultNamespaceName; + } + + public getShortestName(defaultNamespaceName: string | null): string { + if (defaultNamespaceName !== null && this._namespace === defaultNamespaceName) { + return this._name.toLowerCase(); + } + + return this.getName().toLowerCase(); + } + + public isQuoted(): boolean { + return this._quoted; + } + + protected isIdentifierQuoted(identifier: string): boolean { + return identifier.startsWith("`") || identifier.startsWith('"') || identifier.startsWith("["); + } + + protected trimQuotes(identifier: string): string { + return identifier + .replaceAll("`", "") + .replaceAll('"', "") + .replaceAll("[", "") + .replaceAll("]", ""); + } + + public getQuotedName(platform: AbstractPlatform): string { + const keywords = platform.getReservedKeywordsList(); + + return this.getName() + .split(".") + .map((identifier) => { + if (this._quoted || keywords.isKeyword(identifier)) { + return platform.quoteSingleIdentifier(identifier); + } + + return identifier; + }) + .join("."); + } + + protected _generateIdentifierName( + columnNames: readonly string[], + prefix = "", + maxSize = 30, + ): string { + const hash = columnNames + .map((columnName) => createHash("sha1").update(columnName).digest("hex").slice(0, 8)) + .join(""); + + return `${prefix}_${hash}`.slice(0, maxSize).toUpperCase(); + } +} diff --git a/src/schema/abstract-named-object.ts b/src/schema/abstract-named-object.ts new file mode 100644 index 0000000..e8b298a --- /dev/null +++ b/src/schema/abstract-named-object.ts @@ -0,0 +1,8 @@ +import { AbstractAsset } from "./abstract-asset"; +import { NamedObject } from "./named-object"; + +export abstract class AbstractNamedObject extends AbstractAsset implements NamedObject { + public getObjectName(): string { + return this.getName(); + } +} diff --git a/src/schema/abstract-optionally-named-object.ts b/src/schema/abstract-optionally-named-object.ts new file mode 100644 index 0000000..7daf47b --- /dev/null +++ b/src/schema/abstract-optionally-named-object.ts @@ -0,0 +1,22 @@ +import { AbstractAsset } from "./abstract-asset"; +import { OptionallyNamedObject } from "./optionally-named-object"; + +export abstract class AbstractOptionallyNamedObject + extends AbstractAsset + implements OptionallyNamedObject +{ + private readonly hasName: boolean; + + constructor(name: string | null) { + super(name ?? ""); + this.hasName = name !== null; + } + + public getObjectName(): string | null { + if (!this.hasName) { + return null; + } + + return this.getName(); + } +} diff --git a/src/schema/abstract-schema-manager.ts b/src/schema/abstract-schema-manager.ts new file mode 100644 index 0000000..2024e2c --- /dev/null +++ b/src/schema/abstract-schema-manager.ts @@ -0,0 +1,91 @@ +import type { Connection } from "../connection"; +import type { AbstractPlatform } from "../platforms/abstract-platform"; +import { Schema } from "./schema"; +import { Table } from "./table"; +import { View } from "./view"; + +/** + * Base class for schema managers. + * + * This initial port exposes object-name introspection and schema assembly, + * with room for deeper table-definition introspection in follow-up steps. + */ +export abstract class AbstractSchemaManager { + constructor( + protected readonly connection: Connection, + protected readonly platform: AbstractPlatform, + ) {} + + public async listTableNames(): Promise { + const names = await this.connection.fetchFirstColumn(this.getListTableNamesSQL()); + const filter = this.connection.getConfiguration().getSchemaAssetsFilter(); + + return names + .map((value) => normalizeName(value)) + .filter((value): value is string => value !== null) + .filter((value) => filter(value)); + } + + public async listTables(): Promise { + const names = await this.listTableNames(); + return names.map((name) => new Table(name)); + } + + public async tableExists(tableName: string): Promise { + const names = await this.listTableNames(); + const normalized = tableName.toLowerCase(); + + return names.some((name) => name.toLowerCase() === normalized); + } + + public async listViewNames(): Promise { + const sql = this.getListViewNamesSQL(); + if (sql === null) { + return []; + } + + const names = await this.connection.fetchFirstColumn(sql); + const filter = this.connection.getConfiguration().getSchemaAssetsFilter(); + + return names + .map((value) => normalizeName(value)) + .filter((value): value is string => value !== null) + .filter((value) => filter(value)); + } + + public async listViews(): Promise { + const names = await this.listViewNames(); + return names.map((name) => new View(name, "")); + } + + public async createSchema(): Promise { + const tables = await this.listTables(); + return new Schema(tables); + } + + public getConnection(): Connection { + return this.connection; + } + + public getDatabasePlatform(): AbstractPlatform { + return this.platform; + } + + protected getListViewNamesSQL(): string | null { + return null; + } + + protected abstract getListTableNamesSQL(): string; +} + +function normalizeName(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "bigint") { + return String(value); + } + + return null; +} diff --git a/src/schema/collections/exception.ts b/src/schema/collections/exception.ts new file mode 100644 index 0000000..3156079 --- /dev/null +++ b/src/schema/collections/exception.ts @@ -0,0 +1 @@ +export interface Exception extends Error {} diff --git a/src/schema/collections/exception/object-already-exists.ts b/src/schema/collections/exception/object-already-exists.ts new file mode 100644 index 0000000..0d55ecc --- /dev/null +++ b/src/schema/collections/exception/object-already-exists.ts @@ -0,0 +1,20 @@ +export class ObjectAlreadyExists extends Error { + private readonly objectName: string; + + constructor(message: string, objectName: string) { + super(message); + this.name = "ObjectAlreadyExists"; + this.objectName = objectName; + } + + public getObjectName(): { toString(): string } { + return { + toString: () => this.objectName, + }; + } + + public static new(objectName: unknown): ObjectAlreadyExists { + const normalized = typeof objectName === "string" ? objectName : String(objectName); + return new ObjectAlreadyExists(`Object ${normalized} already exists.`, normalized); + } +} diff --git a/src/schema/collections/exception/object-does-not-exist.ts b/src/schema/collections/exception/object-does-not-exist.ts new file mode 100644 index 0000000..95f5338 --- /dev/null +++ b/src/schema/collections/exception/object-does-not-exist.ts @@ -0,0 +1,20 @@ +export class ObjectDoesNotExist extends Error { + private readonly objectName: string; + + constructor(message: string, objectName: string) { + super(message); + this.name = "ObjectDoesNotExist"; + this.objectName = objectName; + } + + public getObjectName(): { toString(): string } { + return { + toString: () => this.objectName, + }; + } + + public static new(objectName: unknown): ObjectDoesNotExist { + const normalized = typeof objectName === "string" ? objectName : String(objectName); + return new ObjectDoesNotExist(`Object ${normalized} does not exist.`, normalized); + } +} diff --git a/src/schema/collections/index.ts b/src/schema/collections/index.ts new file mode 100644 index 0000000..30f438f --- /dev/null +++ b/src/schema/collections/index.ts @@ -0,0 +1,6 @@ +export type { Exception as CollectionsException } from "./exception"; +export { ObjectAlreadyExists } from "./exception/object-already-exists"; +export { ObjectDoesNotExist } from "./exception/object-does-not-exist"; +export { ObjectSet } from "./object-set"; +export { OptionallyUnqualifiedNamedObjectSet } from "./optionally-unqualified-named-object-set"; +export { UnqualifiedNamedObjectSet } from "./unqualified-named-object-set"; diff --git a/src/schema/collections/object-set.ts b/src/schema/collections/object-set.ts new file mode 100644 index 0000000..ae7eac4 --- /dev/null +++ b/src/schema/collections/object-set.ts @@ -0,0 +1,8 @@ +export interface ObjectSet extends Iterable { + add(object: TObject): this; + hasByName(name: string): boolean; + getByName(name: string): TObject; + removeByName(name: string): void; + clear(): void; + toArray(): TObject[]; +} diff --git a/src/schema/collections/optionally-unqualified-named-object-set.ts b/src/schema/collections/optionally-unqualified-named-object-set.ts new file mode 100644 index 0000000..2d0c829 --- /dev/null +++ b/src/schema/collections/optionally-unqualified-named-object-set.ts @@ -0,0 +1,64 @@ +import { ObjectAlreadyExists } from "./exception/object-already-exists"; +import { ObjectDoesNotExist } from "./exception/object-does-not-exist"; +import { ObjectSet } from "./object-set"; + +export class OptionallyUnqualifiedNamedObjectSet + implements ObjectSet +{ + private readonly namedValues = new Map(); + private readonly unnamedValues: TObject[] = []; + + public add(object: TObject): this { + const name = object.getObjectName(); + + if (name === null) { + this.unnamedValues.push(object); + return this; + } + + const key = normalizeName(name); + if (this.namedValues.has(key)) { + throw ObjectAlreadyExists.new(name); + } + + this.namedValues.set(key, object); + return this; + } + + public hasByName(name: string): boolean { + return this.namedValues.has(normalizeName(name)); + } + + public getByName(name: string): TObject { + const object = this.namedValues.get(normalizeName(name)); + if (object === undefined) { + throw ObjectDoesNotExist.new(name); + } + + return object; + } + + public removeByName(name: string): void { + const key = normalizeName(name); + if (!this.namedValues.delete(key)) { + throw ObjectDoesNotExist.new(name); + } + } + + public clear(): void { + this.namedValues.clear(); + this.unnamedValues.length = 0; + } + + public toArray(): TObject[] { + return [...this.namedValues.values(), ...this.unnamedValues]; + } + + public [Symbol.iterator](): Iterator { + return this.toArray()[Symbol.iterator](); + } +} + +function normalizeName(name: string): string { + return name.toLowerCase(); +} diff --git a/src/schema/collections/unqualified-named-object-set.ts b/src/schema/collections/unqualified-named-object-set.ts new file mode 100644 index 0000000..7be5335 --- /dev/null +++ b/src/schema/collections/unqualified-named-object-set.ts @@ -0,0 +1,55 @@ +import { ObjectAlreadyExists } from "./exception/object-already-exists"; +import { ObjectDoesNotExist } from "./exception/object-does-not-exist"; +import { ObjectSet } from "./object-set"; + +export class UnqualifiedNamedObjectSet + implements ObjectSet +{ + private readonly values = new Map(); + + public add(object: TObject): this { + const key = normalizeName(object.getName()); + if (this.values.has(key)) { + throw ObjectAlreadyExists.new(object.getName()); + } + + this.values.set(key, object); + return this; + } + + public hasByName(name: string): boolean { + return this.values.has(normalizeName(name)); + } + + public getByName(name: string): TObject { + const object = this.values.get(normalizeName(name)); + if (object === undefined) { + throw ObjectDoesNotExist.new(name); + } + + return object; + } + + public removeByName(name: string): void { + const key = normalizeName(name); + if (!this.values.delete(key)) { + throw ObjectDoesNotExist.new(name); + } + } + + public clear(): void { + this.values.clear(); + } + + public toArray(): TObject[] { + return [...this.values.values()]; + } + + public [Symbol.iterator](): Iterator { + return this.values.values(); + } +} + +function normalizeName(name: string): string { + return name.toLowerCase(); +} diff --git a/src/schema/column-diff.ts b/src/schema/column-diff.ts new file mode 100644 index 0000000..4213a70 --- /dev/null +++ b/src/schema/column-diff.ts @@ -0,0 +1,13 @@ +import { Column } from "./column"; + +export class ColumnDiff { + constructor( + public readonly oldColumn: Column, + public readonly newColumn: Column, + public readonly changedProperties: readonly string[], + ) {} + + public hasChanges(): boolean { + return this.changedProperties.length > 0; + } +} diff --git a/src/schema/column-editor.ts b/src/schema/column-editor.ts new file mode 100644 index 0000000..1aba94e --- /dev/null +++ b/src/schema/column-editor.ts @@ -0,0 +1,101 @@ +import { Type } from "../types/type"; +import { Column } from "./column"; +import { InvalidColumnDefinition } from "./exception/index"; + +export class ColumnEditor { + private name: string | null = null; + private type: Type | null = null; + private options: Record = {}; + + public setName(name: string): this { + this.name = name; + return this; + } + + public setType(type: Type): this { + this.type = type; + return this; + } + + public setTypeName(typeName: string): this { + this.type = Type.getType(typeName); + return this; + } + + public setLength(length: number | null): this { + this.options.length = length; + return this; + } + + public setPrecision(precision: number | null): this { + this.options.precision = precision; + return this; + } + + public setScale(scale: number): this { + this.options.scale = scale; + return this; + } + + public setUnsigned(unsigned: boolean): this { + this.options.unsigned = unsigned; + return this; + } + + public setFixed(fixed: boolean): this { + this.options.fixed = fixed; + return this; + } + + public setNotNull(notNull: boolean): this { + this.options.notnull = notNull; + return this; + } + + public setDefaultValue(defaultValue: unknown): this { + this.options.default = defaultValue; + return this; + } + + public setAutoincrement(autoincrement: boolean): this { + this.options.autoincrement = autoincrement; + return this; + } + + public setComment(comment: string): this { + this.options.comment = comment; + return this; + } + + public setValues(values: string[]): this { + this.options.values = [...values]; + return this; + } + + public setCharset(charset: string | null): this { + this.options.charset = charset; + return this; + } + + public setCollation(collation: string | null): this { + this.options.collation = collation; + return this; + } + + public setColumnDefinition(columnDefinition: string | null): this { + this.options.columnDefinition = columnDefinition; + return this; + } + + public create(): Column { + if (this.name === null) { + throw InvalidColumnDefinition.nameNotSpecified(); + } + + if (this.type === null) { + throw InvalidColumnDefinition.dataTypeNotSpecified(this.name); + } + + return new Column(this.name, this.type, this.options); + } +} diff --git a/src/schema/column.ts b/src/schema/column.ts new file mode 100644 index 0000000..e1786b4 --- /dev/null +++ b/src/schema/column.ts @@ -0,0 +1,286 @@ +import { Type, registerBuiltInTypes } from "../types/index"; +import { AbstractAsset } from "./abstract-asset"; +import { ColumnEditor } from "./column-editor"; +import { UnknownColumnOption } from "./exception/index"; + +export type ColumnOptions = Record; + +export class Column extends AbstractAsset { + private type: Type; + private length: number | null = null; + private precision: number | null = null; + private scale = 0; + private unsigned = false; + private fixed = false; + private notnull = true; + private defaultValue: unknown = null; + private autoincrement = false; + private values: string[] = []; + private platformOptions: Record = {}; + private columnDefinition: string | null = null; + private comment = ""; + + constructor(name: string, type: string | Type, options: ColumnOptions = {}) { + super(name); + registerBuiltInTypes(); + this.type = typeof type === "string" ? Type.getType(type) : type; + this.setOptions(options); + } + + public setOptions(options: ColumnOptions): this { + for (const [name, value] of Object.entries(options)) { + switch (name) { + case "length": + this.setLength(asNullableNumber(value)); + break; + case "precision": + this.setPrecision(asNullableNumber(value)); + break; + case "scale": + this.setScale(asNumber(value, 0)); + break; + case "unsigned": + this.setUnsigned(Boolean(value)); + break; + case "fixed": + this.setFixed(Boolean(value)); + break; + case "notnull": + this.setNotnull(Boolean(value)); + break; + case "default": + this.setDefault(value); + break; + case "autoincrement": + this.setAutoincrement(Boolean(value)); + break; + case "values": + this.setValues(Array.isArray(value) ? value.map((item) => String(item)) : []); + break; + case "columnDefinition": + this.setColumnDefinition(value === null ? null : String(value)); + break; + case "comment": + this.setComment(String(value)); + break; + case "platformOptions": + if (value && typeof value === "object" && !Array.isArray(value)) { + this.setPlatformOptions(value as Record); + break; + } + + this.setPlatformOptions({}); + break; + case "charset": + case "collation": + case "enumType": + case "jsonb": + case "version": + case "default_constraint_name": + case "min": + case "max": + this.setPlatformOption(name, value); + break; + default: + throw UnknownColumnOption.new(name); + } + } + + return this; + } + + public setType(type: Type): this { + this.type = type; + return this; + } + + public getType(): Type { + return this.type; + } + + public setLength(length: number | null): this { + this.length = length; + return this; + } + + public getLength(): number | null { + return this.length; + } + + public setPrecision(precision: number | null): this { + this.precision = precision; + return this; + } + + public getPrecision(): number | null { + return this.precision; + } + + public setScale(scale: number): this { + this.scale = scale; + return this; + } + + public getScale(): number { + return this.scale; + } + + public setUnsigned(unsigned: boolean): this { + this.unsigned = unsigned; + return this; + } + + public getUnsigned(): boolean { + return this.unsigned; + } + + public setFixed(fixed: boolean): this { + this.fixed = fixed; + return this; + } + + public getFixed(): boolean { + return this.fixed; + } + + public setNotnull(notnull: boolean): this { + this.notnull = notnull; + return this; + } + + public getNotnull(): boolean { + return this.notnull; + } + + public setDefault(defaultValue: unknown): this { + this.defaultValue = defaultValue; + return this; + } + + public getDefault(): unknown { + return this.defaultValue; + } + + public setAutoincrement(autoincrement: boolean): this { + this.autoincrement = autoincrement; + return this; + } + + public getAutoincrement(): boolean { + return this.autoincrement; + } + + public setComment(comment: string): this { + this.comment = comment; + return this; + } + + public getComment(): string { + return this.comment; + } + + public setValues(values: string[]): this { + this.values = [...values]; + return this; + } + + public getValues(): string[] { + return [...this.values]; + } + + public setPlatformOptions(platformOptions: Record): this { + this.platformOptions = { ...platformOptions }; + return this; + } + + public setPlatformOption(name: string, value: unknown): this { + this.platformOptions[name] = value; + return this; + } + + public getPlatformOptions(): Record { + return { ...this.platformOptions }; + } + + public hasPlatformOption(name: string): boolean { + return Object.hasOwn(this.platformOptions, name); + } + + public getPlatformOption(name: string): unknown { + return this.platformOptions[name]; + } + + public getCharset(): string | null { + const value = this.platformOptions.charset; + return typeof value === "string" ? value : null; + } + + public getCollation(): string | null { + const value = this.platformOptions.collation; + return typeof value === "string" ? value : null; + } + + public getEnumType(): string | null { + const value = this.platformOptions.enumType; + return typeof value === "string" ? value : null; + } + + public setColumnDefinition(columnDefinition: string | null): this { + this.columnDefinition = columnDefinition; + return this; + } + + public getColumnDefinition(): string | null { + return this.columnDefinition; + } + + public toArray(): Record { + return { + autoincrement: this.autoincrement, + columnDefinition: this.columnDefinition, + comment: this.comment, + default: this.defaultValue, + fixed: this.fixed, + length: this.length, + name: this.getName(), + notnull: this.notnull, + precision: this.precision, + scale: this.scale, + type: this.type, + unsigned: this.unsigned, + values: this.values, + ...this.platformOptions, + }; + } + + public static editor(): ColumnEditor { + return new ColumnEditor(); + } + + public edit(): ColumnEditor { + return Column.editor() + .setName(this.getName()) + .setType(this.type) + .setLength(this.length) + .setPrecision(this.precision) + .setScale(this.scale) + .setUnsigned(this.unsigned) + .setFixed(this.fixed) + .setNotNull(this.notnull) + .setDefaultValue(this.defaultValue) + .setAutoincrement(this.autoincrement) + .setComment(this.comment) + .setValues(this.values) + .setCharset(this.getCharset()) + .setCollation(this.getCollation()) + .setColumnDefinition(this.columnDefinition); + } +} + +function asNullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function asNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} diff --git a/src/schema/comparator-config.ts b/src/schema/comparator-config.ts new file mode 100644 index 0000000..6c5b2d6 --- /dev/null +++ b/src/schema/comparator-config.ts @@ -0,0 +1,22 @@ +export interface ComparatorConfigOptions { + detectColumnRenames?: boolean; + detectIndexRenames?: boolean; +} + +export class ComparatorConfig { + private readonly detectColumnRenames: boolean; + private readonly detectIndexRenames: boolean; + + constructor(options: ComparatorConfigOptions = {}) { + this.detectColumnRenames = options.detectColumnRenames ?? false; + this.detectIndexRenames = options.detectIndexRenames ?? false; + } + + public isDetectColumnRenamesEnabled(): boolean { + return this.detectColumnRenames; + } + + public isDetectIndexRenamesEnabled(): boolean { + return this.detectIndexRenames; + } +} diff --git a/src/schema/comparator.ts b/src/schema/comparator.ts new file mode 100644 index 0000000..4f6fff3 --- /dev/null +++ b/src/schema/comparator.ts @@ -0,0 +1,186 @@ +import { Column } from "./column"; +import { ColumnDiff } from "./column-diff"; +import { ComparatorConfig } from "./comparator-config"; +import { Schema } from "./schema"; +import { SchemaDiff } from "./schema-diff"; +import { Table } from "./table"; +import { TableDiff } from "./table-diff"; + +export class Comparator { + constructor(private readonly config: ComparatorConfig = new ComparatorConfig()) {} + + public compareSchemas(oldSchema: Schema, newSchema: Schema): SchemaDiff { + const oldTablesByName = new Map(oldSchema.getTables().map((table) => [table.getName(), table])); + const newTablesByName = new Map(newSchema.getTables().map((table) => [table.getName(), table])); + + const createdTables: Table[] = []; + const alteredTables: TableDiff[] = []; + const droppedTables: Table[] = []; + + for (const [name, newTable] of newTablesByName) { + const oldTable = oldTablesByName.get(name); + if (oldTable === undefined) { + createdTables.push(newTable); + continue; + } + + const diff = this.compareTables(oldTable, newTable); + if (diff?.hasChanges()) { + alteredTables.push(diff); + } + } + + for (const [name, oldTable] of oldTablesByName) { + if (!newTablesByName.has(name)) { + droppedTables.push(oldTable); + } + } + + const oldSequencesByName = new Map( + oldSchema.getSequences().map((sequence) => [sequence.getName(), sequence]), + ); + const newSequencesByName = new Map( + newSchema.getSequences().map((sequence) => [sequence.getName(), sequence]), + ); + + const createdSequences = [...newSequencesByName.entries()] + .filter(([name]) => !oldSequencesByName.has(name)) + .map(([, sequence]) => sequence); + + const droppedSequences = [...oldSequencesByName.entries()] + .filter(([name]) => !newSequencesByName.has(name)) + .map(([, sequence]) => sequence); + + return new SchemaDiff({ + alteredTables, + createdSequences, + createdTables, + droppedSequences, + droppedTables, + }); + } + + public compareTables(oldTable: Table, newTable: Table): TableDiff | null { + const oldColumnsByName = new Map( + oldTable.getColumns().map((column) => [column.getName(), column]), + ); + const newColumnsByName = new Map( + newTable.getColumns().map((column) => [column.getName(), column]), + ); + + const addedColumns: Column[] = []; + const changedColumns: ColumnDiff[] = []; + const droppedColumns: Column[] = []; + + for (const [name, newColumn] of newColumnsByName) { + const oldColumn = oldColumnsByName.get(name); + if (oldColumn === undefined) { + addedColumns.push(newColumn); + continue; + } + + const columnDiff = this.compareColumns(oldColumn, newColumn); + if (columnDiff !== null) { + changedColumns.push(columnDiff); + } + } + + for (const [name, oldColumn] of oldColumnsByName) { + if (!newColumnsByName.has(name)) { + droppedColumns.push(oldColumn); + } + } + + const addedIndexes = newTable + .getIndexes() + .filter( + (index) => + !oldTable.getIndexes().some((oldIndex) => oldIndex.getName() === index.getName()), + ); + + const droppedIndexes = oldTable + .getIndexes() + .filter( + (index) => + !newTable.getIndexes().some((newIndex) => newIndex.getName() === index.getName()), + ); + + const addedForeignKeys = newTable.getForeignKeys().filter((foreignKey) => { + return !oldTable + .getForeignKeys() + .some((oldForeignKey) => oldForeignKey.getName() === foreignKey.getName()); + }); + + const droppedForeignKeys = oldTable.getForeignKeys().filter((foreignKey) => { + return !newTable + .getForeignKeys() + .some((newForeignKey) => newForeignKey.getName() === foreignKey.getName()); + }); + + const diff = new TableDiff(oldTable, newTable, { + addedColumns, + addedForeignKeys, + addedIndexes, + changedColumns, + droppedColumns, + droppedForeignKeys, + droppedIndexes, + }); + + if (!diff.hasChanges() && !this.config.isDetectColumnRenamesEnabled()) { + return null; + } + + return diff; + } + + public compareColumns(oldColumn: Column, newColumn: Column): ColumnDiff | null { + const changedProperties: string[] = []; + + if (oldColumn.getType().constructor !== newColumn.getType().constructor) { + changedProperties.push("type"); + } + + if (oldColumn.getLength() !== newColumn.getLength()) { + changedProperties.push("length"); + } + + if (oldColumn.getPrecision() !== newColumn.getPrecision()) { + changedProperties.push("precision"); + } + + if (oldColumn.getScale() !== newColumn.getScale()) { + changedProperties.push("scale"); + } + + if (oldColumn.getUnsigned() !== newColumn.getUnsigned()) { + changedProperties.push("unsigned"); + } + + if (oldColumn.getFixed() !== newColumn.getFixed()) { + changedProperties.push("fixed"); + } + + if (oldColumn.getNotnull() !== newColumn.getNotnull()) { + changedProperties.push("notnull"); + } + + if (oldColumn.getAutoincrement() !== newColumn.getAutoincrement()) { + changedProperties.push("autoincrement"); + } + + if (oldColumn.getDefault() !== newColumn.getDefault()) { + changedProperties.push("default"); + } + + if (oldColumn.getComment() !== newColumn.getComment()) { + changedProperties.push("comment"); + } + + if (changedProperties.length === 0) { + return null; + } + + return new ColumnDiff(oldColumn, newColumn, changedProperties); + } +} diff --git a/src/schema/db2-schema-manager.ts b/src/schema/db2-schema-manager.ts new file mode 100644 index 0000000..7739ebd --- /dev/null +++ b/src/schema/db2-schema-manager.ts @@ -0,0 +1,11 @@ +import { AbstractSchemaManager } from "./abstract-schema-manager"; + +export class DB2SchemaManager extends AbstractSchemaManager { + protected getListTableNamesSQL(): string { + return "SELECT TABNAME FROM SYSCAT.TABLES WHERE TYPE = 'T' AND TABSCHEMA = CURRENT SCHEMA ORDER BY TABNAME"; + } + + protected getListViewNamesSQL(): string { + return "SELECT VIEWNAME FROM SYSCAT.VIEWS WHERE VIEWSCHEMA = CURRENT SCHEMA ORDER BY VIEWNAME"; + } +} diff --git a/src/schema/default-expression.ts b/src/schema/default-expression.ts new file mode 100644 index 0000000..6b3c01d --- /dev/null +++ b/src/schema/default-expression.ts @@ -0,0 +1,3 @@ +export interface DefaultExpression { + toSQL(): string; +} diff --git a/src/schema/default-expression/current-date.ts b/src/schema/default-expression/current-date.ts new file mode 100644 index 0000000..4abcaa2 --- /dev/null +++ b/src/schema/default-expression/current-date.ts @@ -0,0 +1,7 @@ +import { DefaultExpression } from "../default-expression"; + +export class CurrentDate implements DefaultExpression { + public toSQL(): string { + return "CURRENT_DATE"; + } +} diff --git a/src/schema/default-expression/current-time.ts b/src/schema/default-expression/current-time.ts new file mode 100644 index 0000000..d77d0d2 --- /dev/null +++ b/src/schema/default-expression/current-time.ts @@ -0,0 +1,7 @@ +import { DefaultExpression } from "../default-expression"; + +export class CurrentTime implements DefaultExpression { + public toSQL(): string { + return "CURRENT_TIME"; + } +} diff --git a/src/schema/default-expression/current-timestamp.ts b/src/schema/default-expression/current-timestamp.ts new file mode 100644 index 0000000..462a710 --- /dev/null +++ b/src/schema/default-expression/current-timestamp.ts @@ -0,0 +1,7 @@ +import { DefaultExpression } from "../default-expression"; + +export class CurrentTimestamp implements DefaultExpression { + public toSQL(): string { + return "CURRENT_TIMESTAMP"; + } +} diff --git a/src/schema/default-expression/index.ts b/src/schema/default-expression/index.ts new file mode 100644 index 0000000..9acad71 --- /dev/null +++ b/src/schema/default-expression/index.ts @@ -0,0 +1,3 @@ +export { CurrentDate } from "./current-date"; +export { CurrentTime } from "./current-time"; +export { CurrentTimestamp } from "./current-timestamp"; diff --git a/src/schema/default-schema-manager-factory.ts b/src/schema/default-schema-manager-factory.ts new file mode 100644 index 0000000..09de799 --- /dev/null +++ b/src/schema/default-schema-manager-factory.ts @@ -0,0 +1,12 @@ +import type { Connection } from "../connection"; +import type { AbstractSchemaManager } from "./abstract-schema-manager"; +import type { SchemaManagerFactory } from "./schema-manager-factory"; + +/** + * Default factory: asks the resolved platform for its schema manager implementation. + */ +export class DefaultSchemaManagerFactory implements SchemaManagerFactory { + public createSchemaManager(connection: Connection): AbstractSchemaManager { + return connection.getDatabasePlatform().createSchemaManager(connection); + } +} diff --git a/src/schema/exception/_util.ts b/src/schema/exception/_util.ts new file mode 100644 index 0000000..06c12f5 --- /dev/null +++ b/src/schema/exception/_util.ts @@ -0,0 +1,42 @@ +export function nameToString(value: unknown, unnamed = ""): string { + if (value === null || value === undefined) { + return unnamed; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "object") { + const candidate = value as { toString?: () => string }; + if (typeof candidate.toString === "function") { + return candidate.toString(); + } + } + + return String(value); +} + +export function previousObjectNameToString(previous: unknown): string { + if (typeof previous === "object" && previous !== null) { + const candidate = previous as { getObjectName?: () => unknown }; + if (typeof candidate.getObjectName === "function") { + return nameToString(candidate.getObjectName()); + } + } + + return ""; +} + +export function attachCause(error: T, cause?: unknown): T { + if (cause !== undefined) { + Object.defineProperty(error, "cause", { + configurable: true, + enumerable: false, + value: cause, + writable: true, + }); + } + + return error; +} diff --git a/src/schema/exception/column-already-exists.ts b/src/schema/exception/column-already-exists.ts new file mode 100644 index 0000000..c8a2427 --- /dev/null +++ b/src/schema/exception/column-already-exists.ts @@ -0,0 +1,14 @@ +import type { SchemaException } from "../schema-exception"; + +export class ColumnAlreadyExists extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "ColumnAlreadyExists"; + } + + public static new(tableName: string, columnName: string): ColumnAlreadyExists { + return new ColumnAlreadyExists( + `The column "${columnName}" on table "${tableName}" already exists.`, + ); + } +} diff --git a/src/schema/exception/column-does-not-exist.ts b/src/schema/exception/column-does-not-exist.ts new file mode 100644 index 0000000..ea44794 --- /dev/null +++ b/src/schema/exception/column-does-not-exist.ts @@ -0,0 +1,14 @@ +import type { SchemaException } from "../schema-exception"; + +export class ColumnDoesNotExist extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "ColumnDoesNotExist"; + } + + public static new(columnName: string, table: string): ColumnDoesNotExist { + return new ColumnDoesNotExist( + `There is no column with name "${columnName}" on table "${table}".`, + ); + } +} diff --git a/src/schema/exception/foreign-key-does-not-exist.ts b/src/schema/exception/foreign-key-does-not-exist.ts new file mode 100644 index 0000000..776e9f2 --- /dev/null +++ b/src/schema/exception/foreign-key-does-not-exist.ts @@ -0,0 +1,14 @@ +import type { SchemaException } from "../schema-exception"; + +export class ForeignKeyDoesNotExist extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "ForeignKeyDoesNotExist"; + } + + public static new(foreignKeyName: string, table: string): ForeignKeyDoesNotExist { + return new ForeignKeyDoesNotExist( + `There exists no foreign key with the name "${foreignKeyName}" on table "${table}".`, + ); + } +} diff --git a/src/schema/exception/incomparable-names.ts b/src/schema/exception/incomparable-names.ts new file mode 100644 index 0000000..1d16060 --- /dev/null +++ b/src/schema/exception/incomparable-names.ts @@ -0,0 +1,15 @@ +import type { SchemaException } from "../schema-exception"; +import { nameToString } from "./_util"; + +export class IncomparableNames extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "IncomparableNames"; + } + + public static fromOptionallyQualifiedNames(name1: unknown, name2: unknown): IncomparableNames { + return new IncomparableNames( + `Non-equally qualified names are incomparable: ${nameToString(name1)}, ${nameToString(name2)}.`, + ); + } +} diff --git a/src/schema/exception/index-already-exists.ts b/src/schema/exception/index-already-exists.ts new file mode 100644 index 0000000..546a406 --- /dev/null +++ b/src/schema/exception/index-already-exists.ts @@ -0,0 +1,14 @@ +import type { SchemaException } from "../schema-exception"; + +export class IndexAlreadyExists extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "IndexAlreadyExists"; + } + + public static new(indexName: string, table: string): IndexAlreadyExists { + return new IndexAlreadyExists( + `An index with name "${indexName}" was already defined on table "${table}".`, + ); + } +} diff --git a/src/schema/exception/index-does-not-exist.ts b/src/schema/exception/index-does-not-exist.ts new file mode 100644 index 0000000..1f03fb3 --- /dev/null +++ b/src/schema/exception/index-does-not-exist.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class IndexDoesNotExist extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "IndexDoesNotExist"; + } + + public static new(indexName: string, table: string): IndexDoesNotExist { + return new IndexDoesNotExist(`Index "${indexName}" does not exist on table "${table}".`); + } +} diff --git a/src/schema/exception/index-name-invalid.ts b/src/schema/exception/index-name-invalid.ts new file mode 100644 index 0000000..4b4f684 --- /dev/null +++ b/src/schema/exception/index-name-invalid.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class IndexNameInvalid extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "IndexNameInvalid"; + } + + public static new(indexName: string): IndexNameInvalid { + return new IndexNameInvalid(`Invalid index name "${indexName}" given, has to be [a-zA-Z0-9_].`); + } +} diff --git a/src/schema/exception/index.ts b/src/schema/exception/index.ts new file mode 100644 index 0000000..f9f4283 --- /dev/null +++ b/src/schema/exception/index.ts @@ -0,0 +1,31 @@ +export { ColumnAlreadyExists } from "./column-already-exists"; +export { ColumnDoesNotExist } from "./column-does-not-exist"; +export { ForeignKeyDoesNotExist } from "./foreign-key-does-not-exist"; +export { IncomparableNames } from "./incomparable-names"; +export { IndexAlreadyExists } from "./index-already-exists"; +export { IndexDoesNotExist } from "./index-does-not-exist"; +export { IndexNameInvalid } from "./index-name-invalid"; +export { InvalidColumnDefinition } from "./invalid-column-definition"; +export { InvalidForeignKeyConstraintDefinition } from "./invalid-foreign-key-constraint-definition"; +export { InvalidIdentifier } from "./invalid-identifier"; +export { InvalidIndexDefinition } from "./invalid-index-definition"; +export { InvalidName } from "./invalid-name"; +export { InvalidPrimaryKeyConstraintDefinition } from "./invalid-primary-key-constraint-definition"; +export { InvalidSequenceDefinition } from "./invalid-sequence-definition"; +export { InvalidState } from "./invalid-state"; +export { InvalidTableDefinition } from "./invalid-table-definition"; +export { InvalidTableModification } from "./invalid-table-modification"; +export { InvalidTableName } from "./invalid-table-name"; +export { InvalidUniqueConstraintDefinition } from "./invalid-unique-constraint-definition"; +export { InvalidViewDefinition } from "./invalid-view-definition"; +export { NamespaceAlreadyExists } from "./namespace-already-exists"; +export { NotImplemented } from "./not-implemented"; +export { PrimaryKeyAlreadyExists } from "./primary-key-already-exists"; +export { SequenceAlreadyExists } from "./sequence-already-exists"; +export { SequenceDoesNotExist } from "./sequence-does-not-exist"; +export { TableAlreadyExists } from "./table-already-exists"; +export { TableDoesNotExist } from "./table-does-not-exist"; +export { UniqueConstraintDoesNotExist } from "./unique-constraint-does-not-exist"; +export { UnknownColumnOption } from "./unknown-column-option"; +export { UnsupportedName } from "./unsupported-name"; +export { UnsupportedSchema } from "./unsupported-schema"; diff --git a/src/schema/exception/invalid-column-definition.ts b/src/schema/exception/invalid-column-definition.ts new file mode 100644 index 0000000..9322b84 --- /dev/null +++ b/src/schema/exception/invalid-column-definition.ts @@ -0,0 +1,19 @@ +import type { SchemaException } from "../schema-exception"; +import { nameToString } from "./_util"; + +export class InvalidColumnDefinition extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidColumnDefinition"; + } + + public static nameNotSpecified(): InvalidColumnDefinition { + return new InvalidColumnDefinition("Column name is not specified."); + } + + public static dataTypeNotSpecified(columnName: unknown): InvalidColumnDefinition { + return new InvalidColumnDefinition( + `Data type is not specified for column ${nameToString(columnName)}.`, + ); + } +} diff --git a/src/schema/exception/invalid-foreign-key-constraint-definition.ts b/src/schema/exception/invalid-foreign-key-constraint-definition.ts new file mode 100644 index 0000000..03acff0 --- /dev/null +++ b/src/schema/exception/invalid-foreign-key-constraint-definition.ts @@ -0,0 +1,33 @@ +import type { SchemaException } from "../schema-exception"; +import { nameToString } from "./_util"; + +export class InvalidForeignKeyConstraintDefinition extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidForeignKeyConstraintDefinition"; + } + + public static referencedTableNameNotSet( + constraintName: unknown, + ): InvalidForeignKeyConstraintDefinition { + return new InvalidForeignKeyConstraintDefinition( + `Referenced table name is not set for foreign key constraint ${nameToString(constraintName)}.`, + ); + } + + public static referencingColumnNamesNotSet( + constraintName: unknown, + ): InvalidForeignKeyConstraintDefinition { + return new InvalidForeignKeyConstraintDefinition( + `Referencing column names are not set for foreign key constraint ${nameToString(constraintName)}.`, + ); + } + + public static referencedColumnNamesNotSet( + constraintName: unknown, + ): InvalidForeignKeyConstraintDefinition { + return new InvalidForeignKeyConstraintDefinition( + `Referenced column names are not set for foreign key constraint ${nameToString(constraintName)}.`, + ); + } +} diff --git a/src/schema/exception/invalid-identifier.ts b/src/schema/exception/invalid-identifier.ts new file mode 100644 index 0000000..224047c --- /dev/null +++ b/src/schema/exception/invalid-identifier.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class InvalidIdentifier extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidIdentifier"; + } + + public static fromEmpty(): InvalidIdentifier { + return new InvalidIdentifier("Identifier cannot be empty."); + } +} diff --git a/src/schema/exception/invalid-index-definition.ts b/src/schema/exception/invalid-index-definition.ts new file mode 100644 index 0000000..c9fa455 --- /dev/null +++ b/src/schema/exception/invalid-index-definition.ts @@ -0,0 +1,26 @@ +import type { SchemaException } from "../schema-exception"; +import { nameToString } from "./_util"; + +export class InvalidIndexDefinition extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidIndexDefinition"; + } + + public static nameNotSet(): InvalidIndexDefinition { + return new InvalidIndexDefinition("Index name is not set."); + } + + public static columnsNotSet(indexName: unknown): InvalidIndexDefinition { + return new InvalidIndexDefinition(`Columns are not set for index ${nameToString(indexName)}.`); + } + + public static fromNonPositiveColumnLength( + columnName: unknown, + length: number, + ): InvalidIndexDefinition { + return new InvalidIndexDefinition( + `Indexed column length must be a positive integer, ${length} given for column ${nameToString(columnName)}.`, + ); + } +} diff --git a/src/schema/exception/invalid-name.ts b/src/schema/exception/invalid-name.ts new file mode 100644 index 0000000..5df4233 --- /dev/null +++ b/src/schema/exception/invalid-name.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class InvalidName extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidName"; + } + + public static fromEmpty(): InvalidName { + return new InvalidName("Name cannot be empty."); + } +} diff --git a/src/schema/exception/invalid-primary-key-constraint-definition.ts b/src/schema/exception/invalid-primary-key-constraint-definition.ts new file mode 100644 index 0000000..b97c88d --- /dev/null +++ b/src/schema/exception/invalid-primary-key-constraint-definition.ts @@ -0,0 +1,14 @@ +import type { SchemaException } from "../schema-exception"; + +export class InvalidPrimaryKeyConstraintDefinition extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidPrimaryKeyConstraintDefinition"; + } + + public static columnNamesNotSet(): InvalidPrimaryKeyConstraintDefinition { + return new InvalidPrimaryKeyConstraintDefinition( + "Primary key constraint column names are not set.", + ); + } +} diff --git a/src/schema/exception/invalid-sequence-definition.ts b/src/schema/exception/invalid-sequence-definition.ts new file mode 100644 index 0000000..1914a4a --- /dev/null +++ b/src/schema/exception/invalid-sequence-definition.ts @@ -0,0 +1,18 @@ +import type { SchemaException } from "../schema-exception"; + +export class InvalidSequenceDefinition extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidSequenceDefinition"; + } + + public static nameNotSet(): InvalidSequenceDefinition { + return new InvalidSequenceDefinition("Sequence name is not set."); + } + + public static fromNegativeCacheSize(cacheSize: number): InvalidSequenceDefinition { + return new InvalidSequenceDefinition( + `Sequence cache size must be a non-negative integer, ${cacheSize} given.`, + ); + } +} diff --git a/src/schema/exception/invalid-state.ts b/src/schema/exception/invalid-state.ts new file mode 100644 index 0000000..633ca86 --- /dev/null +++ b/src/schema/exception/invalid-state.ts @@ -0,0 +1,74 @@ +import type { SchemaException } from "../schema-exception"; + +export class InvalidState extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidState"; + } + + public static objectNameNotInitialized(): InvalidState { + return new InvalidState("Object name has not been initialized."); + } + public static indexHasInvalidType(indexName: string): InvalidState { + return new InvalidState(`Index "${indexName}" has invalid type.`); + } + public static indexHasInvalidPredicate(indexName: string): InvalidState { + return new InvalidState(`Index "${indexName}" has invalid predicate.`); + } + public static indexHasInvalidColumns(indexName: string): InvalidState { + return new InvalidState(`Index "${indexName}" has invalid columns.`); + } + public static foreignKeyConstraintHasInvalidReferencedTableName( + constraintName: string, + ): InvalidState { + return new InvalidState( + `Foreign key constraint "${constraintName}" has invalid referenced table name.`, + ); + } + public static foreignKeyConstraintHasInvalidReferencingColumnNames( + constraintName: string, + ): InvalidState { + return new InvalidState( + `Foreign key constraint "${constraintName}" has one or more invalid referencing column names.`, + ); + } + public static foreignKeyConstraintHasInvalidReferencedColumnNames( + constraintName: string, + ): InvalidState { + return new InvalidState( + `Foreign key constraint "${constraintName}" has one or more invalid referenced column name.`, + ); + } + public static foreignKeyConstraintHasInvalidMatchType(constraintName: string): InvalidState { + return new InvalidState(`Foreign key constraint "${constraintName}" has invalid match type.`); + } + public static foreignKeyConstraintHasInvalidOnUpdateAction(constraintName: string): InvalidState { + return new InvalidState( + `Foreign key constraint "${constraintName}" has invalid ON UPDATE action.`, + ); + } + public static foreignKeyConstraintHasInvalidOnDeleteAction(constraintName: string): InvalidState { + return new InvalidState( + `Foreign key constraint "${constraintName}" has invalid ON DELETE action.`, + ); + } + public static foreignKeyConstraintHasInvalidDeferrability(constraintName: string): InvalidState { + return new InvalidState( + `Foreign key constraint "${constraintName}" has invalid deferrability.`, + ); + } + public static uniqueConstraintHasInvalidColumnNames(constraintName: string): InvalidState { + return new InvalidState( + `Unique constraint "${constraintName}" has one or more invalid column names.`, + ); + } + public static uniqueConstraintHasEmptyColumnNames(constraintName: string): InvalidState { + return new InvalidState(`Unique constraint "${constraintName}" has no column names.`); + } + public static tableHasInvalidPrimaryKeyConstraint(tableName: string): InvalidState { + return new InvalidState(`Table "${tableName}" has invalid primary key constraint.`); + } + public static tableDiffContainsUnnamedDroppedForeignKeyConstraints(): InvalidState { + return new InvalidState("Table diff contains unnamed dropped foreign key constraints"); + } +} diff --git a/src/schema/exception/invalid-table-definition.ts b/src/schema/exception/invalid-table-definition.ts new file mode 100644 index 0000000..b8dedd3 --- /dev/null +++ b/src/schema/exception/invalid-table-definition.ts @@ -0,0 +1,15 @@ +import type { SchemaException } from "../schema-exception"; +import { nameToString } from "./_util"; + +export class InvalidTableDefinition extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidTableDefinition"; + } + public static nameNotSet(): InvalidTableDefinition { + return new InvalidTableDefinition("Table name is not set."); + } + public static columnsNotSet(tableName: unknown): InvalidTableDefinition { + return new InvalidTableDefinition(`Columns are not set for table ${nameToString(tableName)}.`); + } +} diff --git a/src/schema/exception/invalid-table-modification.ts b/src/schema/exception/invalid-table-modification.ts new file mode 100644 index 0000000..99a1f26 --- /dev/null +++ b/src/schema/exception/invalid-table-modification.ts @@ -0,0 +1,161 @@ +import type { SchemaException } from "../schema-exception"; +import { attachCause, nameToString, previousObjectNameToString } from "./_util"; + +export class InvalidTableModification extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidTableModification"; + } + + public static columnAlreadyExists( + tableName: unknown, + previous: unknown, + ): InvalidTableModification { + return attachCause( + new InvalidTableModification( + `Column ${previousObjectNameToString(previous)} already exists on table ${InvalidTableModification.formatTableName(tableName)}.`, + ), + previous, + ); + } + + public static columnDoesNotExist( + tableName: unknown, + previous: unknown, + ): InvalidTableModification { + return attachCause( + new InvalidTableModification( + `Column ${previousObjectNameToString(previous)} does not exist on table ${InvalidTableModification.formatTableName(tableName)}.`, + ), + previous, + ); + } + + public static indexAlreadyExists( + tableName: unknown, + previous: unknown, + ): InvalidTableModification { + return attachCause( + new InvalidTableModification( + `Index ${previousObjectNameToString(previous)} already exists on table ${InvalidTableModification.formatTableName(tableName)}.`, + ), + previous, + ); + } + + public static indexDoesNotExist(tableName: unknown, previous: unknown): InvalidTableModification { + return attachCause( + new InvalidTableModification( + `Index ${previousObjectNameToString(previous)} does not exist on table ${InvalidTableModification.formatTableName(tableName)}.`, + ), + previous, + ); + } + + public static primaryKeyConstraintAlreadyExists(tableName: unknown): InvalidTableModification { + return new InvalidTableModification( + `Primary key constraint already exists on table ${InvalidTableModification.formatTableName(tableName)}.`, + ); + } + + public static primaryKeyConstraintDoesNotExist(tableName: unknown): InvalidTableModification { + return new InvalidTableModification( + `Primary key constraint does not exist on table ${InvalidTableModification.formatTableName(tableName)}.`, + ); + } + + public static uniqueConstraintAlreadyExists( + tableName: unknown, + previous: unknown, + ): InvalidTableModification { + return attachCause( + new InvalidTableModification( + `Unique constraint ${previousObjectNameToString(previous)} already exists on table ${InvalidTableModification.formatTableName(tableName)}.`, + ), + previous, + ); + } + + public static uniqueConstraintDoesNotExist( + tableName: unknown, + previous: unknown, + ): InvalidTableModification { + return attachCause( + new InvalidTableModification( + `Unique constraint ${previousObjectNameToString(previous)} does not exist on table ${InvalidTableModification.formatTableName(tableName)}.`, + ), + previous, + ); + } + + public static foreignKeyConstraintAlreadyExists( + tableName: unknown, + previous: unknown, + ): InvalidTableModification { + return attachCause( + new InvalidTableModification( + `Foreign key constraint ${previousObjectNameToString(previous)} already exists on table ${InvalidTableModification.formatTableName(tableName)}.`, + ), + previous, + ); + } + + public static foreignKeyConstraintDoesNotExist( + tableName: unknown, + previous: unknown, + ): InvalidTableModification { + return attachCause( + new InvalidTableModification( + `Foreign key constraint ${previousObjectNameToString(previous)} does not exist on table ${InvalidTableModification.formatTableName(tableName)}.`, + ), + previous, + ); + } + + public static indexedColumnDoesNotExist( + tableName: unknown, + indexName: unknown, + columnName: unknown, + ): InvalidTableModification { + return new InvalidTableModification( + `Column ${nameToString(columnName)} referenced by index ${nameToString(indexName)} does not exist on table ${InvalidTableModification.formatTableName(tableName)}.`, + ); + } + + public static primaryKeyConstraintColumnDoesNotExist( + tableName: unknown, + constraintName: unknown, + columnName: unknown, + ): InvalidTableModification { + return new InvalidTableModification( + `Column ${nameToString(columnName)} referenced by primary key constraint ${InvalidTableModification.formatConstraintName(constraintName)} does not exist on table ${InvalidTableModification.formatTableName(tableName)}.`, + ); + } + + public static uniqueConstraintColumnDoesNotExist( + tableName: unknown, + constraintName: unknown, + columnName: unknown, + ): InvalidTableModification { + return new InvalidTableModification( + `Column ${nameToString(columnName)} referenced by unique constraint ${InvalidTableModification.formatConstraintName(constraintName)} does not exist on table ${InvalidTableModification.formatTableName(tableName)}.`, + ); + } + + public static foreignKeyConstraintReferencingColumnDoesNotExist( + tableName: unknown, + constraintName: unknown, + columnName: unknown, + ): InvalidTableModification { + return new InvalidTableModification( + `Referencing column ${nameToString(columnName)} of foreign key constraint ${InvalidTableModification.formatConstraintName(constraintName)} does not exist on table ${InvalidTableModification.formatTableName(tableName)}.`, + ); + } + + private static formatTableName(tableName: unknown): string { + return nameToString(tableName); + } + private static formatConstraintName(constraintName: unknown): string { + return nameToString(constraintName); + } +} diff --git a/src/schema/exception/invalid-table-name.ts b/src/schema/exception/invalid-table-name.ts new file mode 100644 index 0000000..75bbaa1 --- /dev/null +++ b/src/schema/exception/invalid-table-name.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class InvalidTableName extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidTableName"; + } + + public static new(tableName: string): InvalidTableName { + return new InvalidTableName(`Invalid table name specified "${tableName}".`); + } +} diff --git a/src/schema/exception/invalid-unique-constraint-definition.ts b/src/schema/exception/invalid-unique-constraint-definition.ts new file mode 100644 index 0000000..8d17c4e --- /dev/null +++ b/src/schema/exception/invalid-unique-constraint-definition.ts @@ -0,0 +1,15 @@ +import type { SchemaException } from "../schema-exception"; +import { nameToString } from "./_util"; + +export class InvalidUniqueConstraintDefinition extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidUniqueConstraintDefinition"; + } + + public static columnNamesAreNotSet(constraintName: unknown): InvalidUniqueConstraintDefinition { + return new InvalidUniqueConstraintDefinition( + `Column names are not set for unique constraint ${nameToString(constraintName)}.`, + ); + } +} diff --git a/src/schema/exception/invalid-view-definition.ts b/src/schema/exception/invalid-view-definition.ts new file mode 100644 index 0000000..8463bcd --- /dev/null +++ b/src/schema/exception/invalid-view-definition.ts @@ -0,0 +1,16 @@ +import type { SchemaException } from "../schema-exception"; +import { nameToString } from "./_util"; + +export class InvalidViewDefinition extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "InvalidViewDefinition"; + } + + public static nameNotSet(): InvalidViewDefinition { + return new InvalidViewDefinition("View name is not set."); + } + public static sqlNotSet(viewName: unknown): InvalidViewDefinition { + return new InvalidViewDefinition(`SQL is not set for view ${nameToString(viewName)}.`); + } +} diff --git a/src/schema/exception/namespace-already-exists.ts b/src/schema/exception/namespace-already-exists.ts new file mode 100644 index 0000000..5273d6f --- /dev/null +++ b/src/schema/exception/namespace-already-exists.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class NamespaceAlreadyExists extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "NamespaceAlreadyExists"; + } + + public static new(namespaceName: string): NamespaceAlreadyExists { + return new NamespaceAlreadyExists(`The namespace with name "${namespaceName}" already exists.`); + } +} diff --git a/src/schema/exception/not-implemented.ts b/src/schema/exception/not-implemented.ts new file mode 100644 index 0000000..a375c2a --- /dev/null +++ b/src/schema/exception/not-implemented.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class NotImplemented extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "NotImplemented"; + } + + public static fromMethod(className: string, method: string): NotImplemented { + return new NotImplemented(`Class ${className} does not implement method ${method}().`); + } +} diff --git a/src/schema/exception/primary-key-already-exists.ts b/src/schema/exception/primary-key-already-exists.ts new file mode 100644 index 0000000..b10a053 --- /dev/null +++ b/src/schema/exception/primary-key-already-exists.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class PrimaryKeyAlreadyExists extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "PrimaryKeyAlreadyExists"; + } + + public static new(tableName: string): PrimaryKeyAlreadyExists { + return new PrimaryKeyAlreadyExists(`Primary key was already defined on table "${tableName}".`); + } +} diff --git a/src/schema/exception/sequence-already-exists.ts b/src/schema/exception/sequence-already-exists.ts new file mode 100644 index 0000000..2e19fcb --- /dev/null +++ b/src/schema/exception/sequence-already-exists.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class SequenceAlreadyExists extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "SequenceAlreadyExists"; + } + + public static new(sequenceName: string): SequenceAlreadyExists { + return new SequenceAlreadyExists(`The sequence "${sequenceName}" already exists.`); + } +} diff --git a/src/schema/exception/sequence-does-not-exist.ts b/src/schema/exception/sequence-does-not-exist.ts new file mode 100644 index 0000000..5e8041b --- /dev/null +++ b/src/schema/exception/sequence-does-not-exist.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class SequenceDoesNotExist extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "SequenceDoesNotExist"; + } + + public static new(sequenceName: string): SequenceDoesNotExist { + return new SequenceDoesNotExist(`There exists no sequence with the name "${sequenceName}".`); + } +} diff --git a/src/schema/exception/table-already-exists.ts b/src/schema/exception/table-already-exists.ts new file mode 100644 index 0000000..38b2693 --- /dev/null +++ b/src/schema/exception/table-already-exists.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class TableAlreadyExists extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "TableAlreadyExists"; + } + + public static new(tableName: string): TableAlreadyExists { + return new TableAlreadyExists(`The table with name "${tableName}" already exists.`); + } +} diff --git a/src/schema/exception/table-does-not-exist.ts b/src/schema/exception/table-does-not-exist.ts new file mode 100644 index 0000000..fd42a61 --- /dev/null +++ b/src/schema/exception/table-does-not-exist.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class TableDoesNotExist extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "TableDoesNotExist"; + } + + public static new(tableName: string): TableDoesNotExist { + return new TableDoesNotExist(`There is no table with name "${tableName}" in the schema.`); + } +} diff --git a/src/schema/exception/unique-constraint-does-not-exist.ts b/src/schema/exception/unique-constraint-does-not-exist.ts new file mode 100644 index 0000000..baf5ab0 --- /dev/null +++ b/src/schema/exception/unique-constraint-does-not-exist.ts @@ -0,0 +1,14 @@ +import type { SchemaException } from "../schema-exception"; + +export class UniqueConstraintDoesNotExist extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "UniqueConstraintDoesNotExist"; + } + + public static new(constraintName: string, table: string): UniqueConstraintDoesNotExist { + return new UniqueConstraintDoesNotExist( + `There exists no unique constraint with the name "${constraintName}" on table "${table}".`, + ); + } +} diff --git a/src/schema/exception/unknown-column-option.ts b/src/schema/exception/unknown-column-option.ts new file mode 100644 index 0000000..f45c5cf --- /dev/null +++ b/src/schema/exception/unknown-column-option.ts @@ -0,0 +1,12 @@ +import type { SchemaException } from "../schema-exception"; + +export class UnknownColumnOption extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "UnknownColumnOption"; + } + + public static new(name: string): UnknownColumnOption { + return new UnknownColumnOption(`The "${name}" column option is not supported.`); + } +} diff --git a/src/schema/exception/unsupported-name.ts b/src/schema/exception/unsupported-name.ts new file mode 100644 index 0000000..2e35239 --- /dev/null +++ b/src/schema/exception/unsupported-name.ts @@ -0,0 +1,18 @@ +import type { SchemaException } from "../schema-exception"; + +export class UnsupportedName extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "UnsupportedName"; + } + + public static fromNonNullSchemaName(schemaName: string, methodName: string): UnsupportedName { + return new UnsupportedName( + `${methodName}() does not accept schema names, "${schemaName}" given.`, + ); + } + + public static fromNullSchemaName(methodName: string): UnsupportedName { + return new UnsupportedName(`${methodName}() requires a schema name, null given.`); + } +} diff --git a/src/schema/exception/unsupported-schema.ts b/src/schema/exception/unsupported-schema.ts new file mode 100644 index 0000000..72382c7 --- /dev/null +++ b/src/schema/exception/unsupported-schema.ts @@ -0,0 +1,20 @@ +import type { SchemaException } from "../schema-exception"; + +export class UnsupportedSchema extends Error implements SchemaException { + constructor(message: string) { + super(message); + this.name = "UnsupportedSchema"; + } + + public static sqliteMissingForeignKeyConstraintReferencedColumns( + constraintName: string | null, + referencingTableName: string, + referencedTableName: string, + ): UnsupportedSchema { + const constraintReference = constraintName !== null ? `"${constraintName}"` : ""; + + return new UnsupportedSchema( + `Unable to introspect foreign key constraint ${constraintReference} on table "${referencingTableName}" because the referenced column names are omitted, and the referenced table "${referencedTableName}" does not exist or does not have a primary key.`, + ); + } +} diff --git a/src/schema/foreign-key-constraint-editor.ts b/src/schema/foreign-key-constraint-editor.ts new file mode 100644 index 0000000..721830c --- /dev/null +++ b/src/schema/foreign-key-constraint-editor.ts @@ -0,0 +1,124 @@ +import { InvalidForeignKeyConstraintDefinition } from "./exception/index"; +import { ForeignKeyConstraint } from "./foreign-key-constraint"; +import { Deferrability } from "./foreign-key-constraint/deferrability"; +import { MatchType } from "./foreign-key-constraint/match-type"; +import { ReferentialAction } from "./foreign-key-constraint/referential-action"; +import { OptionallyQualifiedName } from "./name/optionally-qualified-name"; +import { UnqualifiedName } from "./name/unqualified-name"; + +export class ForeignKeyConstraintEditor { + private name: string | null = null; + private referencingColumnNames: string[] = []; + private referencedTableName: string | null = null; + private referencedColumnNames: string[] = []; + private options: Record = {}; + private localTableName: string | null = null; + + public setName(name: string | null): this { + this.name = name; + return this; + } + + public setReferencingColumnNames(...referencingColumnNames: string[]): this { + this.referencingColumnNames = [...referencingColumnNames]; + return this; + } + + public setReferencedTableName(referencedTableName: string | OptionallyQualifiedName): this { + this.referencedTableName = + typeof referencedTableName === "string" + ? referencedTableName + : referencedTableName.toString(); + return this; + } + + public setReferencedColumnNames(...referencedColumnNames: string[]): this { + this.referencedColumnNames = [...referencedColumnNames]; + return this; + } + + public setOnUpdate(onUpdate: string | null): this { + if (onUpdate === null) { + delete this.options.onUpdate; + } else { + this.options.onUpdate = onUpdate; + } + + return this; + } + + public setOnDelete(onDelete: string | null): this { + if (onDelete === null) { + delete this.options.onDelete; + } else { + this.options.onDelete = onDelete; + } + + return this; + } + + public setOptions(options: Record): this { + this.options = { ...options }; + return this; + } + + public setLocalTableName(localTableName: string | null): this { + this.localTableName = localTableName; + return this; + } + + public addReferencingColumnName(columnName: string | UnqualifiedName): this { + this.referencingColumnNames.push( + typeof columnName === "string" ? columnName : columnName.toString(), + ); + return this; + } + + public addReferencedColumnName(columnName: string | UnqualifiedName): this { + this.referencedColumnNames.push( + typeof columnName === "string" ? columnName : columnName.toString(), + ); + return this; + } + + public setMatchType(matchType: MatchType): this { + this.options.match = matchType; + return this; + } + + public setOnUpdateAction(onUpdate: ReferentialAction): this { + return this.setOnUpdate(onUpdate); + } + + public setOnDeleteAction(onDelete: ReferentialAction): this { + return this.setOnDelete(onDelete); + } + + public setDeferrability(deferrability: Deferrability): this { + this.options.deferrability = deferrability; + return this; + } + + public create(): ForeignKeyConstraint { + if (this.referencingColumnNames.length === 0) { + throw InvalidForeignKeyConstraintDefinition.referencingColumnNamesNotSet(this.name); + } + + if (this.referencedTableName === null) { + throw InvalidForeignKeyConstraintDefinition.referencedTableNameNotSet(this.name); + } + + if (this.referencedColumnNames.length === 0) { + throw InvalidForeignKeyConstraintDefinition.referencedColumnNamesNotSet(this.name); + } + + return new ForeignKeyConstraint( + this.referencingColumnNames, + this.referencedTableName, + this.referencedColumnNames, + this.name, + this.options, + this.localTableName, + ); + } +} diff --git a/src/schema/foreign-key-constraint.ts b/src/schema/foreign-key-constraint.ts new file mode 100644 index 0000000..e9c5131 --- /dev/null +++ b/src/schema/foreign-key-constraint.ts @@ -0,0 +1,111 @@ +import type { AbstractPlatform } from "../platforms/abstract-platform"; +import { AbstractAsset } from "./abstract-asset"; +import { ForeignKeyConstraintEditor } from "./foreign-key-constraint-editor"; +import { Identifier } from "./identifier"; + +export class ForeignKeyConstraint extends AbstractAsset { + private readonly localColumns: Identifier[]; + private readonly foreignColumns: Identifier[]; + + constructor( + localColumnNames: string[], + private readonly foreignTableName: string, + foreignColumnNames: string[], + name: string | null = null, + private readonly options: Record = {}, + private localTableName: string | null = null, + ) { + super(name ?? ""); + this.localColumns = localColumnNames.map((columnName) => new Identifier(columnName)); + this.foreignColumns = foreignColumnNames.map((columnName) => new Identifier(columnName)); + } + + public getLocalTableName(): string | null { + return this.localTableName; + } + + public setLocalTableName(localTableName: string): this { + this.localTableName = localTableName; + return this; + } + + public getForeignTableName(): string { + return this.foreignTableName; + } + + public getColumns(): string[] { + return this.localColumns.map((column) => column.getName()); + } + + public getReferencingColumnNames(): string[] { + return this.getColumns(); + } + + public getQuotedLocalColumns(platform: AbstractPlatform): string[] { + return this.localColumns.map((column) => column.getQuotedName(platform)); + } + + public getForeignColumns(): string[] { + return this.foreignColumns.map((column) => column.getName()); + } + + public getReferencedColumnNames(): string[] { + return this.getForeignColumns(); + } + + public getQuotedForeignColumns(platform: AbstractPlatform): string[] { + return this.foreignColumns.map((column) => column.getQuotedName(platform)); + } + + public onUpdate(): string | null { + const value = this.options.onUpdate; + return typeof value === "string" ? value : null; + } + + public onDelete(): string | null { + const value = this.options.onDelete; + return typeof value === "string" ? value : null; + } + + public hasOption(name: string): boolean { + return Object.hasOwn(this.options, name); + } + + public getOption(name: string): unknown { + return this.options[name]; + } + + public getOptions(): Record { + return { ...this.options }; + } + + public intersectsIndexColumns(columnNames: string[]): boolean { + const local = this.getColumns().map(normalize); + const columns = columnNames.map(normalize); + + return columns.some((column) => local.includes(column)); + } + + public static editor(): ForeignKeyConstraintEditor { + return new ForeignKeyConstraintEditor(); + } + + public edit(): ForeignKeyConstraintEditor { + const editor = ForeignKeyConstraint.editor() + .setName(this.getName()) + .setReferencingColumnNames(...this.getColumns()) + .setReferencedTableName(this.foreignTableName) + .setReferencedColumnNames(...this.getForeignColumns()) + .setOptions(this.getOptions()) + .setLocalTableName(this.localTableName); + + editor.setOnUpdate(this.onUpdate()); + editor.setOnDelete(this.onDelete()); + + return editor; + } +} + +function normalize(identifier: string): string { + return identifier.replaceAll(/[`"[\]]/g, "").toLowerCase(); +} diff --git a/src/schema/foreign-key-constraint/deferrability.ts b/src/schema/foreign-key-constraint/deferrability.ts new file mode 100644 index 0000000..9206e18 --- /dev/null +++ b/src/schema/foreign-key-constraint/deferrability.ts @@ -0,0 +1,9 @@ +export enum Deferrability { + NOT_DEFERRABLE = "NOT DEFERRABLE", + DEFERRABLE = "DEFERRABLE", + DEFERRED = "INITIALLY DEFERRED", +} + +export function deferrabilityToSQL(deferrability: Deferrability): string { + return deferrability; +} diff --git a/src/schema/foreign-key-constraint/index.ts b/src/schema/foreign-key-constraint/index.ts new file mode 100644 index 0000000..3ea38d1 --- /dev/null +++ b/src/schema/foreign-key-constraint/index.ts @@ -0,0 +1,3 @@ +export { Deferrability, deferrabilityToSQL } from "./deferrability"; +export { MatchType, matchTypeToSQL } from "./match-type"; +export { ReferentialAction, referentialActionToSQL } from "./referential-action"; diff --git a/src/schema/foreign-key-constraint/match-type.ts b/src/schema/foreign-key-constraint/match-type.ts new file mode 100644 index 0000000..5932c4d --- /dev/null +++ b/src/schema/foreign-key-constraint/match-type.ts @@ -0,0 +1,9 @@ +export enum MatchType { + FULL = "FULL", + PARTIAL = "PARTIAL", + SIMPLE = "SIMPLE", +} + +export function matchTypeToSQL(matchType: MatchType): string { + return matchType; +} diff --git a/src/schema/foreign-key-constraint/referential-action.ts b/src/schema/foreign-key-constraint/referential-action.ts new file mode 100644 index 0000000..9874620 --- /dev/null +++ b/src/schema/foreign-key-constraint/referential-action.ts @@ -0,0 +1,11 @@ +export enum ReferentialAction { + CASCADE = "CASCADE", + NO_ACTION = "NO ACTION", + SET_DEFAULT = "SET DEFAULT", + SET_NULL = "SET NULL", + RESTRICT = "RESTRICT", +} + +export function referentialActionToSQL(referentialAction: ReferentialAction): string { + return referentialAction; +} diff --git a/src/schema/identifier.ts b/src/schema/identifier.ts new file mode 100644 index 0000000..1b6aaeb --- /dev/null +++ b/src/schema/identifier.ts @@ -0,0 +1,14 @@ +import { AbstractAsset } from "./abstract-asset"; + +/** + * Wrapper around raw SQL identifiers (table names, column names, etc.). + */ +export class Identifier extends AbstractAsset { + constructor(identifier: string, quote = false) { + super(identifier); + + if (quote && !this._quoted) { + this._setName(`"${this.getName()}"`); + } + } +} diff --git a/src/schema/index-editor.ts b/src/schema/index-editor.ts new file mode 100644 index 0000000..1d0a1b7 --- /dev/null +++ b/src/schema/index-editor.ts @@ -0,0 +1,126 @@ +import { InvalidIndexDefinition } from "./exception/index"; +import { Index } from "./index"; +import { IndexType } from "./index/index-type"; +import { IndexedColumn } from "./index/indexed-column"; + +export class IndexEditor { + private name: string | null = null; + private columns: string[] = []; + private unique = false; + private primary = false; + private flags: string[] = []; + private options: Record = {}; + private type: IndexType | null = null; + + public setName(name: string | null): this { + this.name = name; + return this; + } + + public setColumns(...columnNames: string[]): this { + this.columns = [...columnNames]; + return this; + } + + public setIsUnique(unique: boolean): this { + this.unique = unique; + return this; + } + + public setIsPrimary(primary: boolean): this { + this.primary = primary; + return this; + } + + public setType(type: IndexType): this { + this.type = type; + return this; + } + + public setIsClustered(clustered: boolean): this { + return this.setOptions({ ...this.options, clustered }); + } + + public setPredicate(predicate: string | null): this { + const options = { ...this.options }; + if (predicate === null) { + delete options.where; + } else { + options.where = predicate; + } + + return this.setOptions(options); + } + + public setFlags(...flags: string[]): this { + this.flags = [...flags]; + return this; + } + + public setOptions(options: Record): this { + this.options = { ...options }; + return this; + } + + public addColumn(column: IndexedColumn | string): this { + const indexed = typeof column === "string" ? new IndexedColumn(column) : column; + + this.columns.push(indexed.getColumnName().toString()); + + const length = indexed.getLength(); + if (length !== null) { + const lengths = Array.isArray(this.options.lengths) ? [...this.options.lengths] : []; + lengths.push(length); + this.options = { ...this.options, lengths }; + } + + return this; + } + + public create(): Index { + if (this.name === null || this.name.length === 0) { + throw InvalidIndexDefinition.nameNotSet(); + } + + if (this.columns.length === 0) { + throw InvalidIndexDefinition.columnsNotSet(this.name); + } + + const lengths = this.options.lengths; + if (Array.isArray(lengths)) { + for (let index = 0; index < lengths.length; index += 1) { + const length = lengths[index]; + if (typeof length === "number" && length <= 0) { + throw InvalidIndexDefinition.fromNonPositiveColumnLength( + this.columns[index] ?? "", + length, + ); + } + } + } + + const flags = [...this.flags]; + let unique = this.unique; + + switch (this.type) { + case IndexType.UNIQUE: + unique = true; + break; + case IndexType.FULLTEXT: + if (!flags.some((flag) => flag.toLowerCase() === "fulltext")) { + flags.push("fulltext"); + } + break; + case IndexType.SPATIAL: + if (!flags.some((flag) => flag.toLowerCase() === "spatial")) { + flags.push("spatial"); + } + break; + case IndexType.REGULAR: + case null: + break; + } + + return new Index(this.name, this.columns, unique, this.primary, flags, this.options); + } +} diff --git a/src/schema/index.ts b/src/schema/index.ts new file mode 100644 index 0000000..e7b9bf5 --- /dev/null +++ b/src/schema/index.ts @@ -0,0 +1,148 @@ +import type { AbstractPlatform } from "../platforms/abstract-platform"; +import { AbstractAsset } from "./abstract-asset"; +import { Identifier } from "./identifier"; +import { IndexEditor } from "./index-editor"; + +export class Index extends AbstractAsset { + private readonly columns: Identifier[] = []; + private readonly unique: boolean; + private readonly primary: boolean; + private readonly flags = new Set(); + + constructor( + name: string | null, + columns: string[], + isUnique = false, + isPrimary = false, + flags: string[] = [], + private readonly options: Record = {}, + ) { + super(name ?? ""); + + this.unique = isUnique || isPrimary; + this.primary = isPrimary; + + for (const column of columns) { + this.columns.push(new Identifier(column)); + } + + for (const flag of flags) { + this.flags.add(flag.toLowerCase()); + } + } + + public getColumns(): string[] { + return this.columns.map((column) => column.getName()); + } + + public getQuotedColumns(platform: AbstractPlatform): string[] { + const lengths = Array.isArray(this.options.lengths) ? this.options.lengths : []; + + return this.columns.map((column, index) => { + const length = lengths[index]; + const quoted = column.getQuotedName(platform); + + if (typeof length === "number") { + return `${quoted}(${length})`; + } + + return quoted; + }); + } + + public getUnquotedColumns(): string[] { + return this.getColumns().map((column) => column.replaceAll(/[`"[\]]/g, "")); + } + + public isSimpleIndex(): boolean { + return !this.primary && !this.unique; + } + + public isUnique(): boolean { + return this.unique; + } + + public isPrimary(): boolean { + return this.primary; + } + + public hasColumnAtPosition(name: string, pos = 0): boolean { + const normalized = normalizeIdentifier(name); + return normalizeIdentifier(this.getColumns()[pos] ?? "") === normalized; + } + + public spansColumns(columnNames: string[]): boolean { + const ownColumns = this.getColumns(); + if (ownColumns.length !== columnNames.length) { + return false; + } + + return ownColumns.every((column, index) => { + const other = columnNames[index]; + if (other === undefined) { + return false; + } + + return normalizeIdentifier(column) === normalizeIdentifier(other); + }); + } + + public isFulfilledBy(index: Index): boolean { + if (this.columns.length !== index.columns.length) { + return false; + } + + if (this.isUnique() && !index.isUnique()) { + return false; + } + + if (this.isPrimary() !== index.isPrimary()) { + return false; + } + + return this.spansColumns(index.getColumns()); + } + + public addFlag(flag: string): this { + this.flags.add(flag.toLowerCase()); + return this; + } + + public hasFlag(flag: string): boolean { + return this.flags.has(flag.toLowerCase()); + } + + public getFlags(): string[] { + return [...this.flags]; + } + + public hasOption(name: string): boolean { + return Object.hasOwn(this.options, name); + } + + public getOption(name: string): unknown { + return this.options[name]; + } + + public getOptions(): Record { + return { ...this.options }; + } + + public static editor(): IndexEditor { + return new IndexEditor(); + } + + public edit(): IndexEditor { + return Index.editor() + .setName(this.getName()) + .setColumns(...this.getColumns()) + .setIsUnique(this.unique) + .setIsPrimary(this.primary) + .setFlags(...this.getFlags()) + .setOptions(this.getOptions()); + } +} + +function normalizeIdentifier(identifier: string): string { + return identifier.replaceAll(/[`"[\]]/g, "").toLowerCase(); +} diff --git a/src/schema/index/index-type.ts b/src/schema/index/index-type.ts new file mode 100644 index 0000000..d09127b --- /dev/null +++ b/src/schema/index/index-type.ts @@ -0,0 +1,6 @@ +export enum IndexType { + REGULAR = "regular", + UNIQUE = "unique", + FULLTEXT = "fulltext", + SPATIAL = "spatial", +} diff --git a/src/schema/index/index.ts b/src/schema/index/index.ts new file mode 100644 index 0000000..477bea1 --- /dev/null +++ b/src/schema/index/index.ts @@ -0,0 +1,2 @@ +export { IndexType } from "./index-type"; +export { IndexedColumn } from "./indexed-column"; diff --git a/src/schema/index/indexed-column.ts b/src/schema/index/indexed-column.ts new file mode 100644 index 0000000..75e31b7 --- /dev/null +++ b/src/schema/index/indexed-column.ts @@ -0,0 +1,28 @@ +import { InvalidIndexDefinition } from "../exception"; +import { UnqualifiedName } from "../name/unqualified-name"; + +export class IndexedColumn { + private readonly columnName: UnqualifiedName; + private readonly length: number | null; + + constructor(columnName: UnqualifiedName | string, length: number | null = null) { + this.columnName = + typeof columnName === "string" ? UnqualifiedName.unquoted(columnName) : columnName; + this.length = length; + + if (this.length !== null && this.length <= 0) { + throw InvalidIndexDefinition.fromNonPositiveColumnLength( + this.columnName.toString(), + this.length, + ); + } + } + + public getColumnName(): UnqualifiedName { + return this.columnName; + } + + public getLength(): number | null { + return this.length; + } +} diff --git a/src/schema/introspection/index.ts b/src/schema/introspection/index.ts new file mode 100644 index 0000000..4cabaaa --- /dev/null +++ b/src/schema/introspection/index.ts @@ -0,0 +1,2 @@ +export { IntrospectingSchemaProvider } from "./introspecting-schema-provider"; +export * as MetadataProcessor from "./metadata-processor/index"; diff --git a/src/schema/introspection/introspecting-schema-provider.ts b/src/schema/introspection/introspecting-schema-provider.ts new file mode 100644 index 0000000..7fb0321 --- /dev/null +++ b/src/schema/introspection/introspecting-schema-provider.ts @@ -0,0 +1,11 @@ +import { AbstractSchemaManager } from "../abstract-schema-manager"; +import { Schema } from "../schema"; +import { SchemaProvider } from "../schema-provider"; + +export class IntrospectingSchemaProvider implements SchemaProvider { + constructor(private readonly schemaManager: AbstractSchemaManager) {} + + public async createSchema(): Promise { + return this.schemaManager.createSchema(); + } +} diff --git a/src/schema/introspection/metadata-processor/foreign-key-constraint-column-metadata-processor.ts b/src/schema/introspection/metadata-processor/foreign-key-constraint-column-metadata-processor.ts new file mode 100644 index 0000000..beedc69 --- /dev/null +++ b/src/schema/introspection/metadata-processor/foreign-key-constraint-column-metadata-processor.ts @@ -0,0 +1,49 @@ +import { ForeignKeyConstraint } from "../../foreign-key-constraint"; +import { Deferrability } from "../../foreign-key-constraint/deferrability"; +import { ForeignKeyConstraintEditor } from "../../foreign-key-constraint-editor"; +import { ForeignKeyConstraintColumnMetadataRow } from "../../metadata/foreign-key-constraint-column-metadata-row"; +import { OptionallyQualifiedName } from "../../name/optionally-qualified-name"; +import { UnqualifiedName } from "../../name/unqualified-name"; + +export class ForeignKeyConstraintColumnMetadataProcessor { + constructor(private readonly currentSchemaName: string | null = null) {} + + public initializeEditor(row: ForeignKeyConstraintColumnMetadataRow): ForeignKeyConstraintEditor { + const editor = ForeignKeyConstraint.editor(); + + const constraintName = row.getName(); + if (constraintName !== null) { + editor.setName(UnqualifiedName.quoted(constraintName).toString()); + } + + let referencedSchemaName = row.getReferencedSchemaName(); + if (referencedSchemaName === this.currentSchemaName) { + referencedSchemaName = null; + } + + editor + .setReferencedTableName( + OptionallyQualifiedName.quoted(row.getReferencedTableName(), referencedSchemaName), + ) + .setMatchType(row.getMatchType()) + .setOnUpdateAction(row.getOnUpdateAction()) + .setOnDeleteAction(row.getOnDeleteAction()); + + if (row.isDeferred()) { + editor.setDeferrability(Deferrability.DEFERRED); + } else if (row.isDeferrable()) { + editor.setDeferrability(Deferrability.DEFERRABLE); + } + + return editor; + } + + public applyRow( + editor: ForeignKeyConstraintEditor, + row: ForeignKeyConstraintColumnMetadataRow, + ): void { + editor + .addReferencingColumnName(UnqualifiedName.quoted(row.getReferencingColumnName())) + .addReferencedColumnName(UnqualifiedName.quoted(row.getReferencedColumnName())); + } +} diff --git a/src/schema/introspection/metadata-processor/index-column-metadata-processor.ts b/src/schema/introspection/metadata-processor/index-column-metadata-processor.ts new file mode 100644 index 0000000..438722d --- /dev/null +++ b/src/schema/introspection/metadata-processor/index-column-metadata-processor.ts @@ -0,0 +1,21 @@ +import { Index } from "../../index"; +import { IndexedColumn } from "../../index/indexed-column"; +import { IndexEditor } from "../../index-editor"; +import { IndexColumnMetadataRow } from "../../metadata/index-column-metadata-row"; +import { UnqualifiedName } from "../../name/unqualified-name"; + +export class IndexColumnMetadataProcessor { + public initializeEditor(row: IndexColumnMetadataRow): IndexEditor { + return Index.editor() + .setName(UnqualifiedName.quoted(row.getIndexName()).toString()) + .setType(row.getType()) + .setIsClustered(row.isClustered()) + .setPredicate(row.getPredicate()); + } + + public applyRow(editor: IndexEditor, row: IndexColumnMetadataRow): void { + editor.addColumn( + new IndexedColumn(UnqualifiedName.quoted(row.getColumnName()), row.getColumnLength()), + ); + } +} diff --git a/src/schema/introspection/metadata-processor/index.ts b/src/schema/introspection/metadata-processor/index.ts new file mode 100644 index 0000000..ebb50d8 --- /dev/null +++ b/src/schema/introspection/metadata-processor/index.ts @@ -0,0 +1,5 @@ +export { ForeignKeyConstraintColumnMetadataProcessor } from "./foreign-key-constraint-column-metadata-processor"; +export { IndexColumnMetadataProcessor } from "./index-column-metadata-processor"; +export { PrimaryKeyConstraintColumnMetadataProcessor } from "./primary-key-constraint-column-metadata-processor"; +export { SequenceMetadataProcessor } from "./sequence-metadata-processor"; +export { ViewMetadataProcessor } from "./view-metadata-processor"; diff --git a/src/schema/introspection/metadata-processor/primary-key-constraint-column-metadata-processor.ts b/src/schema/introspection/metadata-processor/primary-key-constraint-column-metadata-processor.ts new file mode 100644 index 0000000..b935a7c --- /dev/null +++ b/src/schema/introspection/metadata-processor/primary-key-constraint-column-metadata-processor.ts @@ -0,0 +1,14 @@ +import { PrimaryKeyConstraintColumnRow } from "../../metadata/primary-key-constraint-column-row"; +import { UnqualifiedName } from "../../name/unqualified-name"; +import { PrimaryKeyConstraint } from "../../primary-key-constraint"; +import { PrimaryKeyConstraintEditor } from "../../primary-key-constraint-editor"; + +export class PrimaryKeyConstraintColumnMetadataProcessor { + public initializeEditor(row: PrimaryKeyConstraintColumnRow): PrimaryKeyConstraintEditor { + return PrimaryKeyConstraint.editor().setIsClustered(row.isClustered()); + } + + public applyRow(editor: PrimaryKeyConstraintEditor, row: PrimaryKeyConstraintColumnRow): void { + editor.addColumnName(UnqualifiedName.quoted(row.getColumnName())); + } +} diff --git a/src/schema/introspection/metadata-processor/sequence-metadata-processor.ts b/src/schema/introspection/metadata-processor/sequence-metadata-processor.ts new file mode 100644 index 0000000..e65e483 --- /dev/null +++ b/src/schema/introspection/metadata-processor/sequence-metadata-processor.ts @@ -0,0 +1,13 @@ +import { SequenceMetadataRow } from "../../metadata/sequence-metadata-row"; +import { Sequence } from "../../sequence"; + +export class SequenceMetadataProcessor { + public createObject(row: SequenceMetadataRow): Sequence { + return Sequence.editor() + .setQuotedName(row.getSequenceName(), row.getSchemaName()) + .setAllocationSize(row.getAllocationSize()) + .setInitialValue(row.getInitialValue()) + .setCacheSize(row.getCacheSize()) + .create(); + } +} diff --git a/src/schema/introspection/metadata-processor/view-metadata-processor.ts b/src/schema/introspection/metadata-processor/view-metadata-processor.ts new file mode 100644 index 0000000..031fb75 --- /dev/null +++ b/src/schema/introspection/metadata-processor/view-metadata-processor.ts @@ -0,0 +1,11 @@ +import { ViewMetadataRow } from "../../metadata/view-metadata-row"; +import { View } from "../../view"; + +export class ViewMetadataProcessor { + public createObject(row: ViewMetadataRow): View { + return View.editor() + .setQuotedName(row.getViewName(), row.getSchemaName()) + .setSQL(row.getDefinition()) + .create(); + } +} diff --git a/src/schema/metadata/database-metadata-row.ts b/src/schema/metadata/database-metadata-row.ts new file mode 100644 index 0000000..03cea3e --- /dev/null +++ b/src/schema/metadata/database-metadata-row.ts @@ -0,0 +1,7 @@ +export class DatabaseMetadataRow { + constructor(private readonly databaseName: string) {} + + public getDatabaseName(): string { + return this.databaseName; + } +} diff --git a/src/schema/metadata/foreign-key-constraint-column-metadata-row.ts b/src/schema/metadata/foreign-key-constraint-column-metadata-row.ts new file mode 100644 index 0000000..2263884 --- /dev/null +++ b/src/schema/metadata/foreign-key-constraint-column-metadata-row.ts @@ -0,0 +1,82 @@ +import { MatchType } from "../foreign-key-constraint/match-type"; +import { ReferentialAction } from "../foreign-key-constraint/referential-action"; + +export class ForeignKeyConstraintColumnMetadataRow { + private readonly id: number | string; + + constructor( + private readonly referencingSchemaName: string | null, + private readonly referencingTableName: string, + id: number | string | null, + private readonly name: string | null, + private readonly referencedSchemaName: string | null, + private readonly referencedTableName: string, + private readonly matchType: MatchType, + private readonly onUpdateAction: ReferentialAction, + private readonly onDeleteAction: ReferentialAction, + private readonly deferrable: boolean, + private readonly deferred: boolean, + private readonly referencingColumnName: string, + private readonly referencedColumnName: string, + ) { + if (id !== null) { + this.id = id; + } else if (name !== null) { + this.id = name; + } else { + throw new Error("Either the id or name must be set to a non-null value."); + } + } + + public getSchemaName(): string | null { + return this.referencingSchemaName; + } + + public getTableName(): string { + return this.referencingTableName; + } + + public getId(): number | string { + return this.id; + } + + public getName(): string | null { + return this.name; + } + + public getReferencedSchemaName(): string | null { + return this.referencedSchemaName; + } + + public getReferencedTableName(): string { + return this.referencedTableName; + } + + public getMatchType(): MatchType { + return this.matchType; + } + + public getOnUpdateAction(): ReferentialAction { + return this.onUpdateAction; + } + + public getOnDeleteAction(): ReferentialAction { + return this.onDeleteAction; + } + + public isDeferrable(): boolean { + return this.deferrable; + } + + public isDeferred(): boolean { + return this.deferred; + } + + public getReferencingColumnName(): string { + return this.referencingColumnName; + } + + public getReferencedColumnName(): string { + return this.referencedColumnName; + } +} diff --git a/src/schema/metadata/index-column-metadata-row.ts b/src/schema/metadata/index-column-metadata-row.ts new file mode 100644 index 0000000..c9a91cd --- /dev/null +++ b/src/schema/metadata/index-column-metadata-row.ts @@ -0,0 +1,46 @@ +import { IndexType } from "../index/index-type"; + +export class IndexColumnMetadataRow { + constructor( + private readonly schemaName: string | null, + private readonly tableName: string, + private readonly indexName: string, + private readonly type: IndexType, + private readonly clustered: boolean, + private readonly predicate: string | null, + private readonly columnName: string, + private readonly columnLength: number | null, + ) {} + + public getSchemaName(): string | null { + return this.schemaName; + } + + public getTableName(): string { + return this.tableName; + } + + public getIndexName(): string { + return this.indexName; + } + + public getType(): IndexType { + return this.type; + } + + public isClustered(): boolean { + return this.clustered; + } + + public getPredicate(): string | null { + return this.predicate; + } + + public getColumnName(): string { + return this.columnName; + } + + public getColumnLength(): number | null { + return this.columnLength; + } +} diff --git a/src/schema/metadata/index.ts b/src/schema/metadata/index.ts new file mode 100644 index 0000000..951b27d --- /dev/null +++ b/src/schema/metadata/index.ts @@ -0,0 +1,10 @@ +export { DatabaseMetadataRow } from "./database-metadata-row"; +export { ForeignKeyConstraintColumnMetadataRow } from "./foreign-key-constraint-column-metadata-row"; +export { IndexColumnMetadataRow } from "./index-column-metadata-row"; +export { MetadataProvider } from "./metadata-provider"; +export { PrimaryKeyConstraintColumnRow } from "./primary-key-constraint-column-row"; +export { SchemaMetadataRow } from "./schema-metadata-row"; +export { SequenceMetadataRow } from "./sequence-metadata-row"; +export { TableColumnMetadataRow } from "./table-column-metadata-row"; +export { TableMetadataRow } from "./table-metadata-row"; +export { ViewMetadataRow } from "./view-metadata-row"; diff --git a/src/schema/metadata/metadata-provider.ts b/src/schema/metadata/metadata-provider.ts new file mode 100644 index 0000000..261eab1 --- /dev/null +++ b/src/schema/metadata/metadata-provider.ts @@ -0,0 +1,39 @@ +import type { DatabaseMetadataRow } from "./database-metadata-row"; +import type { ForeignKeyConstraintColumnMetadataRow } from "./foreign-key-constraint-column-metadata-row"; +import type { IndexColumnMetadataRow } from "./index-column-metadata-row"; +import type { PrimaryKeyConstraintColumnRow } from "./primary-key-constraint-column-row"; +import type { SchemaMetadataRow } from "./schema-metadata-row"; +import type { SequenceMetadataRow } from "./sequence-metadata-row"; +import type { TableColumnMetadataRow } from "./table-column-metadata-row"; +import type { TableMetadataRow } from "./table-metadata-row"; +import type { ViewMetadataRow } from "./view-metadata-row"; + +export interface MetadataProvider { + getAllDatabaseNames(): Iterable; + getAllSchemaNames(): Iterable; + getAllTableNames(): Iterable; + getTableColumnsForAllTables(): Iterable; + getTableColumnsForTable( + schemaName: string | null, + tableName: string, + ): Iterable; + getIndexColumnsForAllTables(): Iterable; + getIndexColumnsForTable( + schemaName: string | null, + tableName: string, + ): Iterable; + getPrimaryKeyConstraintColumnsForAllTables(): Iterable; + getPrimaryKeyConstraintColumnsForTable( + schemaName: string | null, + tableName: string, + ): Iterable; + getForeignKeyConstraintColumnsForAllTables(): Iterable; + getForeignKeyConstraintColumnsForTable( + schemaName: string | null, + tableName: string, + ): Iterable; + getTableOptionsForAllTables(): Iterable; + getTableOptionsForTable(schemaName: string | null, tableName: string): Iterable; + getAllViews(): Iterable; + getAllSequences(): Iterable; +} diff --git a/src/schema/metadata/primary-key-constraint-column-row.ts b/src/schema/metadata/primary-key-constraint-column-row.ts new file mode 100644 index 0000000..069fd89 --- /dev/null +++ b/src/schema/metadata/primary-key-constraint-column-row.ts @@ -0,0 +1,29 @@ +export class PrimaryKeyConstraintColumnRow { + constructor( + private readonly schemaName: string | null, + private readonly tableName: string, + private readonly constraintName: string | null, + private readonly clustered: boolean, + private readonly columnName: string, + ) {} + + public getSchemaName(): string | null { + return this.schemaName; + } + + public getTableName(): string { + return this.tableName; + } + + public getConstraintName(): string | null { + return this.constraintName; + } + + public isClustered(): boolean { + return this.clustered; + } + + public getColumnName(): string { + return this.columnName; + } +} diff --git a/src/schema/metadata/schema-metadata-row.ts b/src/schema/metadata/schema-metadata-row.ts new file mode 100644 index 0000000..6d73ac3 --- /dev/null +++ b/src/schema/metadata/schema-metadata-row.ts @@ -0,0 +1,7 @@ +export class SchemaMetadataRow { + constructor(private readonly schemaName: string) {} + + public getSchemaName(): string { + return this.schemaName; + } +} diff --git a/src/schema/metadata/sequence-metadata-row.ts b/src/schema/metadata/sequence-metadata-row.ts new file mode 100644 index 0000000..8fe96b6 --- /dev/null +++ b/src/schema/metadata/sequence-metadata-row.ts @@ -0,0 +1,29 @@ +export class SequenceMetadataRow { + constructor( + private readonly schemaName: string | null, + private readonly sequenceName: string, + private readonly allocationSize: number, + private readonly initialValue: number, + private readonly cacheSize: number | null, + ) {} + + public getSchemaName(): string | null { + return this.schemaName; + } + + public getSequenceName(): string { + return this.sequenceName; + } + + public getAllocationSize(): number { + return this.allocationSize; + } + + public getInitialValue(): number { + return this.initialValue; + } + + public getCacheSize(): number | null { + return this.cacheSize; + } +} diff --git a/src/schema/metadata/table-column-metadata-row.ts b/src/schema/metadata/table-column-metadata-row.ts new file mode 100644 index 0000000..4650711 --- /dev/null +++ b/src/schema/metadata/table-column-metadata-row.ts @@ -0,0 +1,21 @@ +import { Column } from "../column"; + +export class TableColumnMetadataRow { + constructor( + private readonly schemaName: string | null, + private readonly tableName: string, + private readonly column: Column, + ) {} + + public getSchemaName(): string | null { + return this.schemaName; + } + + public getTableName(): string { + return this.tableName; + } + + public getColumn(): Column { + return this.column; + } +} diff --git a/src/schema/metadata/table-metadata-row.ts b/src/schema/metadata/table-metadata-row.ts new file mode 100644 index 0000000..ce54c95 --- /dev/null +++ b/src/schema/metadata/table-metadata-row.ts @@ -0,0 +1,19 @@ +export class TableMetadataRow { + constructor( + private readonly schemaName: string | null, + private readonly tableName: string, + private readonly options: Record, + ) {} + + public getSchemaName(): string | null { + return this.schemaName; + } + + public getTableName(): string { + return this.tableName; + } + + public getOptions(): Record { + return { ...this.options }; + } +} diff --git a/src/schema/metadata/view-metadata-row.ts b/src/schema/metadata/view-metadata-row.ts new file mode 100644 index 0000000..97aeffa --- /dev/null +++ b/src/schema/metadata/view-metadata-row.ts @@ -0,0 +1,19 @@ +export class ViewMetadataRow { + constructor( + private readonly schemaName: string | null, + private readonly viewName: string, + private readonly definition: string, + ) {} + + public getSchemaName(): string | null { + return this.schemaName; + } + + public getViewName(): string { + return this.viewName; + } + + public getDefinition(): string { + return this.definition; + } +} diff --git a/src/schema/module.ts b/src/schema/module.ts new file mode 100644 index 0000000..8835782 --- /dev/null +++ b/src/schema/module.ts @@ -0,0 +1,51 @@ +export { AbstractAsset } from "./abstract-asset"; +export { AbstractNamedObject } from "./abstract-named-object"; +export { AbstractOptionallyNamedObject } from "./abstract-optionally-named-object"; +export { AbstractSchemaManager } from "./abstract-schema-manager"; +export * as Collections from "./collections/index"; +export { Column } from "./column"; +export { ColumnDiff } from "./column-diff"; +export { ColumnEditor } from "./column-editor"; +export { Comparator } from "./comparator"; +export { ComparatorConfig } from "./comparator-config"; +export { DB2SchemaManager } from "./db2-schema-manager"; +export type { DefaultExpression } from "./default-expression"; +export * as DefaultExpressions from "./default-expression/index"; +export { DefaultSchemaManagerFactory } from "./default-schema-manager-factory"; +export * as Exception from "./exception/index"; +export { ForeignKeyConstraint } from "./foreign-key-constraint"; +export * as ForeignKey from "./foreign-key-constraint/index"; +export { ForeignKeyConstraintEditor } from "./foreign-key-constraint-editor"; +export { Identifier } from "./identifier"; +export { Index } from "./index"; +export * as Indexing from "./index/index"; +export { IndexEditor } from "./index-editor"; +export * as Introspection from "./introspection/index"; +export * as Metadata from "./metadata/index"; +export { MySQLSchemaManager } from "./mysql-schema-manager"; +export type { Name } from "./name"; +export * as Names from "./name/index"; +export type { NamedObject } from "./named-object"; +export type { OptionallyNamedObject } from "./optionally-named-object"; +export { OracleSchemaManager } from "./oracle-schema-manager"; +export { PostgreSQLSchemaManager } from "./postgre-sql-schema-manager"; +export { PrimaryKeyConstraint } from "./primary-key-constraint"; +export { PrimaryKeyConstraintEditor } from "./primary-key-constraint-editor"; +export { Schema } from "./schema"; +export { SchemaConfig } from "./schema-config"; +export { SchemaDiff } from "./schema-diff"; +export type { SchemaException } from "./schema-exception"; +export type { SchemaManagerFactory } from "./schema-manager-factory"; +export type { SchemaProvider } from "./schema-provider"; +export { Sequence } from "./sequence"; +export { SequenceEditor } from "./sequence-editor"; +export { SQLServerSchemaManager } from "./sql-server-schema-manager"; +export { SQLiteSchemaManager } from "./sqlite-schema-manager"; +export { Table } from "./table"; +export { TableConfiguration } from "./table-configuration"; +export { TableDiff } from "./table-diff"; +export { TableEditor } from "./table-editor"; +export { UniqueConstraint } from "./unique-constraint"; +export { UniqueConstraintEditor } from "./unique-constraint-editor"; +export { View } from "./view"; +export { ViewEditor } from "./view-editor"; diff --git a/src/schema/mysql-schema-manager.ts b/src/schema/mysql-schema-manager.ts new file mode 100644 index 0000000..b1199ce --- /dev/null +++ b/src/schema/mysql-schema-manager.ts @@ -0,0 +1,11 @@ +import { AbstractSchemaManager } from "./abstract-schema-manager"; + +export class MySQLSchemaManager extends AbstractSchemaManager { + protected getListTableNamesSQL(): string { + return "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME"; + } + + protected getListViewNamesSQL(): string { + return "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'VIEW' ORDER BY TABLE_NAME"; + } +} diff --git a/src/schema/name.ts b/src/schema/name.ts new file mode 100644 index 0000000..e40caa0 --- /dev/null +++ b/src/schema/name.ts @@ -0,0 +1,6 @@ +import type { AbstractPlatform } from "../platforms/abstract-platform"; + +export interface Name { + toSQL(platform: AbstractPlatform): string; + toString(): string; +} diff --git a/src/schema/name/generic-name.ts b/src/schema/name/generic-name.ts new file mode 100644 index 0000000..bb39897 --- /dev/null +++ b/src/schema/name/generic-name.ts @@ -0,0 +1,23 @@ +import type { AbstractPlatform } from "../../platforms/abstract-platform"; +import type { Name } from "../name"; +import { Identifier } from "./identifier"; + +export class GenericName implements Name { + private readonly identifiers: [Identifier, ...Identifier[]]; + + constructor(firstIdentifier: Identifier, ...otherIdentifiers: Identifier[]) { + this.identifiers = [firstIdentifier, ...otherIdentifiers]; + } + + public getIdentifiers(): [Identifier, ...Identifier[]] { + return [...this.identifiers] as [Identifier, ...Identifier[]]; + } + + public toSQL(platform: AbstractPlatform): string { + return this.identifiers.map((identifier) => identifier.toSQL(platform)).join("."); + } + + public toString(): string { + return this.identifiers.map((identifier) => identifier.toString()).join("."); + } +} diff --git a/src/schema/name/identifier.ts b/src/schema/name/identifier.ts new file mode 100644 index 0000000..cd98ee1 --- /dev/null +++ b/src/schema/name/identifier.ts @@ -0,0 +1,63 @@ +import type { AbstractPlatform } from "../../platforms/abstract-platform"; +import { InvalidIdentifier } from "../exception/invalid-identifier"; +import { UnquotedIdentifierFolding, foldUnquotedIdentifier } from "./unquoted-identifier-folding"; + +export class Identifier { + private constructor( + private readonly value: string, + private readonly quoted: boolean, + ) { + if (this.value.length === 0) { + throw InvalidIdentifier.fromEmpty(); + } + } + + public getValue(): string { + return this.value; + } + + public isQuoted(): boolean { + return this.quoted; + } + + public equals(other: Identifier, folding: UnquotedIdentifierFolding): boolean { + if (this === other) { + return true; + } + + return this.toNormalizedValue(folding) === other.toNormalizedValue(folding); + } + + public toSQL(platform: AbstractPlatform): string { + const folding = + typeof platform.getUnquotedIdentifierFolding === "function" + ? platform.getUnquotedIdentifierFolding() + : UnquotedIdentifierFolding.NONE; + + return platform.quoteSingleIdentifier(this.toNormalizedValue(folding)); + } + + public toNormalizedValue(folding: UnquotedIdentifierFolding): string { + if (!this.quoted) { + return foldUnquotedIdentifier(folding, this.value); + } + + return this.value; + } + + public toString(): string { + if (!this.quoted) { + return this.value; + } + + return `"${this.value.replace(/"/g, `""`)}"`; + } + + public static quoted(value: string): Identifier { + return new Identifier(value, true); + } + + public static unquoted(value: string): Identifier { + return new Identifier(value, false); + } +} diff --git a/src/schema/name/index.ts b/src/schema/name/index.ts new file mode 100644 index 0000000..cf73252 --- /dev/null +++ b/src/schema/name/index.ts @@ -0,0 +1,8 @@ +export { GenericName } from "./generic-name"; +export { Identifier } from "./identifier"; +export { OptionallyQualifiedName } from "./optionally-qualified-name"; +export type { Parser } from "./parser"; +export * as ParserNamespace from "./parser/index"; +export { Parsers } from "./parsers"; +export { UnqualifiedName } from "./unqualified-name"; +export { UnquotedIdentifierFolding, foldUnquotedIdentifier } from "./unquoted-identifier-folding"; diff --git a/src/schema/name/optionally-qualified-name.ts b/src/schema/name/optionally-qualified-name.ts new file mode 100644 index 0000000..80aaa2b --- /dev/null +++ b/src/schema/name/optionally-qualified-name.ts @@ -0,0 +1,80 @@ +import type { AbstractPlatform } from "../../platforms/abstract-platform"; +import { IncomparableNames } from "../exception/incomparable-names"; +import type { Name } from "../name"; +import { Identifier } from "./identifier"; +import type { UnquotedIdentifierFolding } from "./unquoted-identifier-folding"; + +export class OptionallyQualifiedName implements Name { + constructor( + private readonly unqualifiedName: Identifier, + private readonly qualifier: Identifier | null, + ) {} + + public getUnqualifiedName(): Identifier { + return this.unqualifiedName; + } + + public getQualifier(): Identifier | null { + return this.qualifier; + } + + public toSQL(platform: AbstractPlatform): string { + const unqualifiedName = this.unqualifiedName.toSQL(platform); + + if (this.qualifier === null) { + return unqualifiedName; + } + + return `${this.qualifier.toSQL(platform)}.${unqualifiedName}`; + } + + public toString(): string { + const unqualifiedName = this.unqualifiedName.toString(); + + if (this.qualifier === null) { + return unqualifiedName; + } + + return `${this.qualifier.toString()}.${unqualifiedName}`; + } + + public equals(other: OptionallyQualifiedName, folding: UnquotedIdentifierFolding): boolean { + if (this === other) { + return true; + } + + if ((this.qualifier === null) !== (other.qualifier === null)) { + throw IncomparableNames.fromOptionallyQualifiedNames(this, other); + } + + if (!this.unqualifiedName.equals(other.getUnqualifiedName(), folding)) { + return false; + } + + return ( + this.qualifier === null || + other.qualifier === null || + this.qualifier.equals(other.qualifier, folding) + ); + } + + public static quoted( + unqualifiedName: string, + qualifier: string | null = null, + ): OptionallyQualifiedName { + return new OptionallyQualifiedName( + Identifier.quoted(unqualifiedName), + qualifier !== null ? Identifier.quoted(qualifier) : null, + ); + } + + public static unquoted( + unqualifiedName: string, + qualifier: string | null = null, + ): OptionallyQualifiedName { + return new OptionallyQualifiedName( + Identifier.unquoted(unqualifiedName), + qualifier !== null ? Identifier.unquoted(qualifier) : null, + ); + } +} diff --git a/src/schema/name/parser.ts b/src/schema/name/parser.ts new file mode 100644 index 0000000..fde71e0 --- /dev/null +++ b/src/schema/name/parser.ts @@ -0,0 +1,5 @@ +import type { Name } from "../name"; + +export interface Parser { + parse(input: string): TName; +} diff --git a/src/schema/name/parser/exception.ts b/src/schema/name/parser/exception.ts new file mode 100644 index 0000000..3156079 --- /dev/null +++ b/src/schema/name/parser/exception.ts @@ -0,0 +1 @@ +export interface Exception extends Error {} diff --git a/src/schema/name/parser/exception/expected-dot.ts b/src/schema/name/parser/exception/expected-dot.ts new file mode 100644 index 0000000..e9febbd --- /dev/null +++ b/src/schema/name/parser/exception/expected-dot.ts @@ -0,0 +1,10 @@ +export class ExpectedDot extends Error { + constructor(message: string) { + super(message); + this.name = "ExpectedDot"; + } + + public static new(position: number, got: string): ExpectedDot { + return new ExpectedDot(`Expected dot at position ${position}, got "${got}".`); + } +} diff --git a/src/schema/name/parser/exception/expected-next-identifier.ts b/src/schema/name/parser/exception/expected-next-identifier.ts new file mode 100644 index 0000000..1060003 --- /dev/null +++ b/src/schema/name/parser/exception/expected-next-identifier.ts @@ -0,0 +1,10 @@ +export class ExpectedNextIdentifier extends Error { + constructor(message: string) { + super(message); + this.name = "ExpectedNextIdentifier"; + } + + public static new(): ExpectedNextIdentifier { + return new ExpectedNextIdentifier("Unexpected end of input. Next identifier expected."); + } +} diff --git a/src/schema/name/parser/exception/index.ts b/src/schema/name/parser/exception/index.ts new file mode 100644 index 0000000..2a52db9 --- /dev/null +++ b/src/schema/name/parser/exception/index.ts @@ -0,0 +1,5 @@ +export type { Exception as ParserException } from "../exception"; +export { ExpectedDot } from "./expected-dot"; +export { ExpectedNextIdentifier } from "./expected-next-identifier"; +export { InvalidName } from "./invalid-name"; +export { UnableToParseIdentifier } from "./unable-to-parse-identifier"; diff --git a/src/schema/name/parser/exception/invalid-name.ts b/src/schema/name/parser/exception/invalid-name.ts new file mode 100644 index 0000000..fbae0d3 --- /dev/null +++ b/src/schema/name/parser/exception/invalid-name.ts @@ -0,0 +1,16 @@ +export class InvalidName extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidName"; + } + + public static forUnqualifiedName(count: number): InvalidName { + return new InvalidName(`An unqualified name must consist of one identifier, ${count} given.`); + } + + public static forOptionallyQualifiedName(count: number): InvalidName { + return new InvalidName( + `An optionally qualified name must consist of one or two identifiers, ${count} given.`, + ); + } +} diff --git a/src/schema/name/parser/exception/unable-to-parse-identifier.ts b/src/schema/name/parser/exception/unable-to-parse-identifier.ts new file mode 100644 index 0000000..f76b4de --- /dev/null +++ b/src/schema/name/parser/exception/unable-to-parse-identifier.ts @@ -0,0 +1,10 @@ +export class UnableToParseIdentifier extends Error { + constructor(message: string) { + super(message); + this.name = "UnableToParseIdentifier"; + } + + public static new(offset: number): UnableToParseIdentifier { + return new UnableToParseIdentifier(`Unable to parse identifier at offset ${offset}.`); + } +} diff --git a/src/schema/name/parser/generic-name-parser.ts b/src/schema/name/parser/generic-name-parser.ts new file mode 100644 index 0000000..d82877b --- /dev/null +++ b/src/schema/name/parser/generic-name-parser.ts @@ -0,0 +1,136 @@ +import { GenericName } from "../generic-name"; +import { Identifier } from "../identifier"; +import type { Parser } from "../parser"; +import { ExpectedDot } from "./exception/expected-dot"; +import { ExpectedNextIdentifier } from "./exception/expected-next-identifier"; +import { UnableToParseIdentifier } from "./exception/unable-to-parse-identifier"; + +export class GenericNameParser implements Parser { + public parse(input: string): GenericName { + let offset = 0; + const identifiers: Identifier[] = []; + + while (true) { + if (offset >= input.length) { + throw ExpectedNextIdentifier.new(); + } + + const parsed = this.parseIdentifier(input, offset); + identifiers.push(parsed.identifier); + offset = parsed.nextOffset; + + if (offset >= input.length) { + break; + } + + const character = input[offset]; + if (character === undefined) { + throw ExpectedNextIdentifier.new(); + } + + if (character !== ".") { + throw ExpectedDot.new(offset, character); + } + + offset += 1; + } + + const [first, ...rest] = identifiers; + if (first === undefined) { + throw ExpectedNextIdentifier.new(); + } + + return new GenericName(first, ...rest); + } + + private parseIdentifier( + input: string, + offset: number, + ): { identifier: Identifier; nextOffset: number } { + const current = input[offset]; + if (current === undefined) { + throw ExpectedNextIdentifier.new(); + } + + if (current === '"') { + return this.parseQuoted(input, offset, '"'); + } + + if (current === "`") { + return this.parseQuoted(input, offset, "`"); + } + + if (current === "[") { + return this.parseQuoted(input, offset, "]"); + } + + if (this.isForbiddenUnquoted(current)) { + throw UnableToParseIdentifier.new(offset); + } + + let end = offset; + while (end < input.length) { + const char = input[end]; + if (char === undefined || this.isForbiddenUnquoted(char)) { + break; + } + + end += 1; + } + + if (end === offset) { + throw UnableToParseIdentifier.new(offset); + } + + return { + identifier: Identifier.unquoted(input.slice(offset, end)), + nextOffset: end, + }; + } + + private parseQuoted( + input: string, + offset: number, + closer: string, + ): { identifier: Identifier; nextOffset: number } { + let cursor = offset + 1; + let value = ""; + + while (cursor < input.length) { + const char = input[cursor]; + if (char === undefined) { + break; + } + + if (char === closer) { + const next = input[cursor + 1]; + if (next === closer) { + value += closer; + cursor += 2; + continue; + } + + return { + identifier: Identifier.quoted(value), + nextOffset: cursor + 1, + }; + } + + value += char; + cursor += 1; + } + + throw UnableToParseIdentifier.new(offset); + } + + private isForbiddenUnquoted(char: string): boolean { + return ( + /\s/.test(char) || + char === "." || + char === '"' || + char === "`" || + char === "[" || + char === "]" + ); + } +} diff --git a/src/schema/name/parser/index.ts b/src/schema/name/parser/index.ts new file mode 100644 index 0000000..d9e3ac8 --- /dev/null +++ b/src/schema/name/parser/index.ts @@ -0,0 +1,5 @@ +export type { Parser } from "../parser"; +export * as Exception from "./exception/index"; +export { GenericNameParser } from "./generic-name-parser"; +export { OptionallyQualifiedNameParser } from "./optionally-qualified-name-parser"; +export { UnqualifiedNameParser } from "./unqualified-name-parser"; diff --git a/src/schema/name/parser/optionally-qualified-name-parser.ts b/src/schema/name/parser/optionally-qualified-name-parser.ts new file mode 100644 index 0000000..80c7044 --- /dev/null +++ b/src/schema/name/parser/optionally-qualified-name-parser.ts @@ -0,0 +1,31 @@ +import { OptionallyQualifiedName } from "../optionally-qualified-name"; +import type { Parser } from "../parser"; +import { InvalidName } from "./exception/invalid-name"; +import { GenericNameParser } from "./generic-name-parser"; + +export class OptionallyQualifiedNameParser implements Parser { + constructor(private readonly genericNameParser: GenericNameParser) {} + + public parse(input: string): OptionallyQualifiedName { + const identifiers = this.genericNameParser.parse(input).getIdentifiers(); + const first = identifiers[0]; + const second = identifiers[1]; + + switch (identifiers.length) { + case 1: + if (first === undefined) { + throw InvalidName.forOptionallyQualifiedName(0); + } + + return new OptionallyQualifiedName(first, null); + case 2: + if (first === undefined || second === undefined) { + throw InvalidName.forOptionallyQualifiedName(identifiers.length); + } + + return new OptionallyQualifiedName(second, first); + default: + throw InvalidName.forOptionallyQualifiedName(identifiers.length); + } + } +} diff --git a/src/schema/name/parser/unqualified-name-parser.ts b/src/schema/name/parser/unqualified-name-parser.ts new file mode 100644 index 0000000..23dce4d --- /dev/null +++ b/src/schema/name/parser/unqualified-name-parser.ts @@ -0,0 +1,23 @@ +import type { Parser } from "../parser"; +import { UnqualifiedName } from "../unqualified-name"; +import { InvalidName } from "./exception/invalid-name"; +import { GenericNameParser } from "./generic-name-parser"; + +export class UnqualifiedNameParser implements Parser { + constructor(private readonly genericNameParser: GenericNameParser) {} + + public parse(input: string): UnqualifiedName { + const identifiers = this.genericNameParser.parse(input).getIdentifiers(); + + if (identifiers.length > 1) { + throw InvalidName.forUnqualifiedName(identifiers.length); + } + + const identifier = identifiers[0]; + if (identifier === undefined) { + throw InvalidName.forUnqualifiedName(0); + } + + return new UnqualifiedName(identifier); + } +} diff --git a/src/schema/name/parsers.ts b/src/schema/name/parsers.ts new file mode 100644 index 0000000..3a9bed4 --- /dev/null +++ b/src/schema/name/parsers.ts @@ -0,0 +1,28 @@ +import { GenericNameParser } from "./parser/generic-name-parser"; +import { OptionallyQualifiedNameParser } from "./parser/optionally-qualified-name-parser"; +import { UnqualifiedNameParser } from "./parser/unqualified-name-parser"; + +export class Parsers { + private static unqualifiedNameParser: UnqualifiedNameParser | null = null; + private static optionallyQualifiedNameParser: OptionallyQualifiedNameParser | null = null; + private static genericNameParser: GenericNameParser | null = null; + + private constructor() {} + + public static getUnqualifiedNameParser(): UnqualifiedNameParser { + Parsers.unqualifiedNameParser ??= new UnqualifiedNameParser(Parsers.getGenericNameParser()); + return Parsers.unqualifiedNameParser; + } + + public static getOptionallyQualifiedNameParser(): OptionallyQualifiedNameParser { + Parsers.optionallyQualifiedNameParser ??= new OptionallyQualifiedNameParser( + Parsers.getGenericNameParser(), + ); + return Parsers.optionallyQualifiedNameParser; + } + + public static getGenericNameParser(): GenericNameParser { + Parsers.genericNameParser ??= new GenericNameParser(); + return Parsers.genericNameParser; + } +} diff --git a/src/schema/name/unqualified-name.ts b/src/schema/name/unqualified-name.ts new file mode 100644 index 0000000..f85173b --- /dev/null +++ b/src/schema/name/unqualified-name.ts @@ -0,0 +1,36 @@ +import type { AbstractPlatform } from "../../platforms/abstract-platform"; +import type { Name } from "../name"; +import { Identifier } from "./identifier"; +import type { UnquotedIdentifierFolding } from "./unquoted-identifier-folding"; + +export class UnqualifiedName implements Name { + constructor(private readonly identifier: Identifier) {} + + public getIdentifier(): Identifier { + return this.identifier; + } + + public toSQL(platform: AbstractPlatform): string { + return this.identifier.toSQL(platform); + } + + public toString(): string { + return this.identifier.toString(); + } + + public equals(other: UnqualifiedName, folding: UnquotedIdentifierFolding): boolean { + if (this === other) { + return true; + } + + return this.identifier.equals(other.getIdentifier(), folding); + } + + public static quoted(value: string): UnqualifiedName { + return new UnqualifiedName(Identifier.quoted(value)); + } + + public static unquoted(value: string): UnqualifiedName { + return new UnqualifiedName(Identifier.unquoted(value)); + } +} diff --git a/src/schema/name/unquoted-identifier-folding.ts b/src/schema/name/unquoted-identifier-folding.ts new file mode 100644 index 0000000..457bf5a --- /dev/null +++ b/src/schema/name/unquoted-identifier-folding.ts @@ -0,0 +1,16 @@ +export enum UnquotedIdentifierFolding { + UPPER = "upper", + LOWER = "lower", + NONE = "none", +} + +export function foldUnquotedIdentifier(folding: UnquotedIdentifierFolding, value: string): string { + switch (folding) { + case UnquotedIdentifierFolding.UPPER: + return value.toUpperCase(); + case UnquotedIdentifierFolding.LOWER: + return value.toLowerCase(); + case UnquotedIdentifierFolding.NONE: + return value; + } +} diff --git a/src/schema/named-object.ts b/src/schema/named-object.ts new file mode 100644 index 0000000..e096ad3 --- /dev/null +++ b/src/schema/named-object.ts @@ -0,0 +1,3 @@ +export interface NamedObject { + getObjectName(): TName; +} diff --git a/src/schema/optionally-named-object.ts b/src/schema/optionally-named-object.ts new file mode 100644 index 0000000..b098dce --- /dev/null +++ b/src/schema/optionally-named-object.ts @@ -0,0 +1,3 @@ +export interface OptionallyNamedObject { + getObjectName(): TName | null; +} diff --git a/src/schema/oracle-schema-manager.ts b/src/schema/oracle-schema-manager.ts new file mode 100644 index 0000000..e03a17f --- /dev/null +++ b/src/schema/oracle-schema-manager.ts @@ -0,0 +1,11 @@ +import { AbstractSchemaManager } from "./abstract-schema-manager"; + +export class OracleSchemaManager extends AbstractSchemaManager { + protected getListTableNamesSQL(): string { + return "SELECT TABLE_NAME FROM USER_TABLES ORDER BY TABLE_NAME"; + } + + protected getListViewNamesSQL(): string { + return "SELECT VIEW_NAME FROM USER_VIEWS ORDER BY VIEW_NAME"; + } +} diff --git a/src/schema/postgre-sql-schema-manager.ts b/src/schema/postgre-sql-schema-manager.ts new file mode 100644 index 0000000..e8dfc79 --- /dev/null +++ b/src/schema/postgre-sql-schema-manager.ts @@ -0,0 +1,11 @@ +import { AbstractSchemaManager } from "./abstract-schema-manager"; + +export class PostgreSQLSchemaManager extends AbstractSchemaManager { + protected getListTableNamesSQL(): string { + return "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = current_schema() ORDER BY tablename"; + } + + protected getListViewNamesSQL(): string { + return "SELECT viewname FROM pg_catalog.pg_views WHERE schemaname = current_schema() ORDER BY viewname"; + } +} diff --git a/src/schema/primary-key-constraint-editor.ts b/src/schema/primary-key-constraint-editor.ts new file mode 100644 index 0000000..9bb9cad --- /dev/null +++ b/src/schema/primary-key-constraint-editor.ts @@ -0,0 +1,32 @@ +import { UnqualifiedName } from "./name/unqualified-name"; +import { PrimaryKeyConstraint } from "./primary-key-constraint"; + +export class PrimaryKeyConstraintEditor { + private name: string | null = null; + private columnNames: string[] = []; + private clustered = false; + + public setName(name: string | null): this { + this.name = name; + return this; + } + + public setColumnNames(...columnNames: string[]): this { + this.columnNames = [...columnNames]; + return this; + } + + public addColumnName(columnName: string | UnqualifiedName): this { + this.columnNames.push(typeof columnName === "string" ? columnName : columnName.toString()); + return this; + } + + public setIsClustered(clustered: boolean): this { + this.clustered = clustered; + return this; + } + + public create(): PrimaryKeyConstraint { + return new PrimaryKeyConstraint(this.name, this.columnNames, this.clustered); + } +} diff --git a/src/schema/primary-key-constraint.ts b/src/schema/primary-key-constraint.ts new file mode 100644 index 0000000..a62046e --- /dev/null +++ b/src/schema/primary-key-constraint.ts @@ -0,0 +1,40 @@ +import { InvalidPrimaryKeyConstraintDefinition } from "./exception/index"; +import { PrimaryKeyConstraintEditor } from "./primary-key-constraint-editor"; + +export class PrimaryKeyConstraint { + constructor( + private readonly name: string | null, + private readonly columnNames: string[], + private readonly clustered: boolean, + ) { + if (this.columnNames.length === 0) { + throw InvalidPrimaryKeyConstraintDefinition.columnNamesNotSet(); + } + } + + public getObjectName(): string | null { + return this.name; + } + + public getColumnNames(): string[] { + return [...this.columnNames]; + } + + public isClustered(): boolean { + return this.clustered; + } + + public static editor(): PrimaryKeyConstraintEditor { + return new PrimaryKeyConstraintEditor(); + } + + public edit(): PrimaryKeyConstraintEditor { + const editor = PrimaryKeyConstraint.editor(); + + if (this.name !== null) { + editor.setName(this.name); + } + + return editor.setColumnNames(...this.columnNames).setIsClustered(this.clustered); + } +} diff --git a/src/schema/schema-config.ts b/src/schema/schema-config.ts new file mode 100644 index 0000000..311da45 --- /dev/null +++ b/src/schema/schema-config.ts @@ -0,0 +1,32 @@ +export class SchemaConfig { + private name: string | null = null; + private defaultTableOptions: Record = {}; + private maxIdentifierLength = 63; + + public getName(): string | null { + return this.name; + } + + public setName(name: string | null): this { + this.name = name; + return this; + } + + public getDefaultTableOptions(): Record { + return { ...this.defaultTableOptions }; + } + + public setDefaultTableOptions(defaultTableOptions: Record): this { + this.defaultTableOptions = { ...defaultTableOptions }; + return this; + } + + public getMaxIdentifierLength(): number { + return this.maxIdentifierLength; + } + + public setMaxIdentifierLength(maxIdentifierLength: number): this { + this.maxIdentifierLength = maxIdentifierLength; + return this; + } +} diff --git a/src/schema/schema-diff.ts b/src/schema/schema-diff.ts new file mode 100644 index 0000000..a75058d --- /dev/null +++ b/src/schema/schema-diff.ts @@ -0,0 +1,49 @@ +import { Sequence } from "./sequence"; +import { Table } from "./table"; +import { TableDiff } from "./table-diff"; + +export interface SchemaDiffOptions { + createdSchemas?: string[]; + droppedSchemas?: string[]; + createdTables?: Table[]; + alteredTables?: TableDiff[]; + droppedTables?: Table[]; + createdSequences?: Sequence[]; + alteredSequences?: Sequence[]; + droppedSequences?: Sequence[]; +} + +export class SchemaDiff { + public readonly createdSchemas: readonly string[]; + public readonly droppedSchemas: readonly string[]; + public readonly createdTables: readonly Table[]; + public readonly alteredTables: readonly TableDiff[]; + public readonly droppedTables: readonly Table[]; + public readonly createdSequences: readonly Sequence[]; + public readonly alteredSequences: readonly Sequence[]; + public readonly droppedSequences: readonly Sequence[]; + + constructor(options: SchemaDiffOptions = {}) { + this.createdSchemas = options.createdSchemas ?? []; + this.droppedSchemas = options.droppedSchemas ?? []; + this.createdTables = options.createdTables ?? []; + this.alteredTables = options.alteredTables ?? []; + this.droppedTables = options.droppedTables ?? []; + this.createdSequences = options.createdSequences ?? []; + this.alteredSequences = options.alteredSequences ?? []; + this.droppedSequences = options.droppedSequences ?? []; + } + + public hasChanges(): boolean { + return ( + this.createdSchemas.length > 0 || + this.droppedSchemas.length > 0 || + this.createdTables.length > 0 || + this.alteredTables.length > 0 || + this.droppedTables.length > 0 || + this.createdSequences.length > 0 || + this.alteredSequences.length > 0 || + this.droppedSequences.length > 0 + ); + } +} diff --git a/src/schema/schema-exception.ts b/src/schema/schema-exception.ts new file mode 100644 index 0000000..128840a --- /dev/null +++ b/src/schema/schema-exception.ts @@ -0,0 +1 @@ +export interface SchemaException extends Error {} diff --git a/src/schema/schema-manager-factory.ts b/src/schema/schema-manager-factory.ts new file mode 100644 index 0000000..560c546 --- /dev/null +++ b/src/schema/schema-manager-factory.ts @@ -0,0 +1,9 @@ +import type { Connection } from "../connection"; +import type { AbstractSchemaManager } from "./abstract-schema-manager"; + +/** + * Extension point for applications that need custom schema manager instances. + */ +export interface SchemaManagerFactory { + createSchemaManager(connection: Connection): AbstractSchemaManager; +} diff --git a/src/schema/schema-provider.ts b/src/schema/schema-provider.ts new file mode 100644 index 0000000..c81c581 --- /dev/null +++ b/src/schema/schema-provider.ts @@ -0,0 +1,5 @@ +import { Schema } from "./schema"; + +export interface SchemaProvider { + createSchema(): Promise; +} diff --git a/src/schema/schema.ts b/src/schema/schema.ts new file mode 100644 index 0000000..3abef04 --- /dev/null +++ b/src/schema/schema.ts @@ -0,0 +1,138 @@ +import { AbstractAsset } from "./abstract-asset"; +import { + NamespaceAlreadyExists, + SequenceAlreadyExists, + SequenceDoesNotExist, + TableAlreadyExists, + TableDoesNotExist, +} from "./exception/index"; +import { SchemaConfig } from "./schema-config"; +import { Sequence } from "./sequence"; +import { Table } from "./table"; + +export class Schema extends AbstractAsset { + private readonly namespaces: Record = {}; + private readonly tables: Record = {}; + private readonly sequences: Record = {}; + + constructor( + tables: Table[] = [], + sequences: Sequence[] = [], + private readonly schemaConfig: SchemaConfig = new SchemaConfig(), + namespaces: string[] = [], + ) { + super(schemaConfig.getName() ?? ""); + + for (const namespace of namespaces) { + this.createNamespace(namespace); + } + + for (const table of tables) { + this.addTable(table); + } + + for (const sequence of sequences) { + this.addSequence(sequence); + } + } + + public getSchemaConfig(): SchemaConfig { + return this.schemaConfig; + } + + public createNamespace(namespace: string): void { + if (this.hasNamespace(namespace)) { + throw NamespaceAlreadyExists.new(namespace); + } + + this.namespaces[namespace.toLowerCase()] = namespace; + } + + public hasNamespace(namespace: string): boolean { + return Object.hasOwn(this.namespaces, namespace.toLowerCase()); + } + + public getNamespaces(): string[] { + return Object.values(this.namespaces); + } + + public createTable(name: string): Table { + const table = new Table(name, [], [], [], this.schemaConfig.getDefaultTableOptions()); + this.addTable(table); + return table; + } + + public addTable(table: Table): void { + const key = getSchemaAssetKey(table.getName()); + if (Object.hasOwn(this.tables, key)) { + throw TableAlreadyExists.new(table.getName()); + } + + this.tables[key] = table; + } + + public hasTable(name: string): boolean { + return Object.hasOwn(this.tables, getSchemaAssetKey(name)); + } + + public getTable(name: string): Table { + const key = getSchemaAssetKey(name); + const table = this.tables[key]; + + if (table === undefined) { + throw TableDoesNotExist.new(name); + } + + return table; + } + + public getTables(): Table[] { + return Object.values(this.tables); + } + + public dropTable(name: string): void { + delete this.tables[getSchemaAssetKey(name)]; + } + + public createSequence(name: string, allocationSize = 1, initialValue = 1): Sequence { + const sequence = new Sequence(name, allocationSize, initialValue); + this.addSequence(sequence); + return sequence; + } + + public addSequence(sequence: Sequence): void { + const key = getSchemaAssetKey(sequence.getName()); + if (Object.hasOwn(this.sequences, key)) { + throw SequenceAlreadyExists.new(sequence.getName()); + } + + this.sequences[key] = sequence; + } + + public hasSequence(name: string): boolean { + return Object.hasOwn(this.sequences, getSchemaAssetKey(name)); + } + + public getSequence(name: string): Sequence { + const key = getSchemaAssetKey(name); + const sequence = this.sequences[key]; + + if (sequence === undefined) { + throw SequenceDoesNotExist.new(name); + } + + return sequence; + } + + public getSequences(): Sequence[] { + return Object.values(this.sequences); + } + + public dropSequence(name: string): void { + delete this.sequences[getSchemaAssetKey(name)]; + } +} + +function getSchemaAssetKey(name: string): string { + return name.toLowerCase(); +} diff --git a/src/schema/sequence-editor.ts b/src/schema/sequence-editor.ts new file mode 100644 index 0000000..6e68bf0 --- /dev/null +++ b/src/schema/sequence-editor.ts @@ -0,0 +1,50 @@ +import { InvalidSequenceDefinition } from "./exception/index"; +import { Sequence } from "./sequence"; + +export class SequenceEditor { + private name: string | null = null; + private allocationSize = 1; + private initialValue = 1; + private cacheSize: number | null = null; + + public setName(name: string): this { + this.name = name; + return this; + } + + public setQuotedName(name: string, schemaName: string | null = null): this { + this.name = schemaName === null ? `"${name}"` : `"${schemaName}"."${name}"`; + return this; + } + + public setAllocationSize(allocationSize: number): this { + this.allocationSize = allocationSize; + return this; + } + + public setInitialValue(initialValue: number): this { + this.initialValue = initialValue; + return this; + } + + public setCacheSize(cacheSize: number | null): this { + this.cacheSize = cacheSize; + return this; + } + + public create(): Sequence { + if (this.name === null) { + throw InvalidSequenceDefinition.nameNotSet(); + } + + if (this.allocationSize < 0) { + throw InvalidSequenceDefinition.fromNegativeCacheSize(this.allocationSize); + } + + if (this.cacheSize !== null && this.cacheSize < 0) { + throw InvalidSequenceDefinition.fromNegativeCacheSize(this.cacheSize); + } + + return new Sequence(this.name, this.allocationSize, this.initialValue, this.cacheSize); + } +} diff --git a/src/schema/sequence.ts b/src/schema/sequence.ts new file mode 100644 index 0000000..4208a53 --- /dev/null +++ b/src/schema/sequence.ts @@ -0,0 +1,37 @@ +import { AbstractAsset } from "./abstract-asset"; +import { SequenceEditor } from "./sequence-editor"; + +export class Sequence extends AbstractAsset { + constructor( + name: string, + private allocationSize: number = 1, + private initialValue: number = 1, + private cacheSize: number | null = null, + ) { + super(name); + } + + public getAllocationSize(): number { + return this.allocationSize; + } + + public getInitialValue(): number { + return this.initialValue; + } + + public getCacheSize(): number | null { + return this.cacheSize; + } + + public static editor(): SequenceEditor { + return new SequenceEditor(); + } + + public edit(): SequenceEditor { + return Sequence.editor() + .setName(this.getName()) + .setAllocationSize(this.allocationSize) + .setInitialValue(this.initialValue) + .setCacheSize(this.cacheSize); + } +} diff --git a/src/schema/sql-server-schema-manager.ts b/src/schema/sql-server-schema-manager.ts new file mode 100644 index 0000000..1088ce9 --- /dev/null +++ b/src/schema/sql-server-schema-manager.ts @@ -0,0 +1,11 @@ +import { AbstractSchemaManager } from "./abstract-schema-manager"; + +export class SQLServerSchemaManager extends AbstractSchemaManager { + protected getListTableNamesSQL(): string { + return "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = SCHEMA_NAME() AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME"; + } + + protected getListViewNamesSQL(): string { + return "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = SCHEMA_NAME() AND TABLE_TYPE = 'VIEW' ORDER BY TABLE_NAME"; + } +} diff --git a/src/schema/sqlite-schema-manager.ts b/src/schema/sqlite-schema-manager.ts new file mode 100644 index 0000000..8804a94 --- /dev/null +++ b/src/schema/sqlite-schema-manager.ts @@ -0,0 +1,11 @@ +import { AbstractSchemaManager } from "./abstract-schema-manager"; + +export class SQLiteSchemaManager extends AbstractSchemaManager { + protected getListTableNamesSQL(): string { + return "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"; + } + + protected getListViewNamesSQL(): string { + return "SELECT name FROM sqlite_master WHERE type = 'view' ORDER BY name"; + } +} diff --git a/src/schema/table-configuration.ts b/src/schema/table-configuration.ts new file mode 100644 index 0000000..03424d8 --- /dev/null +++ b/src/schema/table-configuration.ts @@ -0,0 +1,10 @@ +/** + * Platform-specific parameters used when creating objects scoped to a table. + */ +export class TableConfiguration { + constructor(private readonly maxIdentifierLength: number) {} + + public getMaxIdentifierLength(): number { + return this.maxIdentifierLength; + } +} diff --git a/src/schema/table-diff.ts b/src/schema/table-diff.ts new file mode 100644 index 0000000..69dd618 --- /dev/null +++ b/src/schema/table-diff.ts @@ -0,0 +1,51 @@ +import { Column } from "./column"; +import { ColumnDiff } from "./column-diff"; +import { ForeignKeyConstraint } from "./foreign-key-constraint"; +import { Index } from "./index"; +import { Table } from "./table"; + +export interface TableDiffOptions { + addedColumns?: Column[]; + changedColumns?: ColumnDiff[]; + droppedColumns?: Column[]; + addedIndexes?: Index[]; + droppedIndexes?: Index[]; + addedForeignKeys?: ForeignKeyConstraint[]; + droppedForeignKeys?: ForeignKeyConstraint[]; +} + +export class TableDiff { + public readonly addedColumns: readonly Column[]; + public readonly changedColumns: readonly ColumnDiff[]; + public readonly droppedColumns: readonly Column[]; + public readonly addedIndexes: readonly Index[]; + public readonly droppedIndexes: readonly Index[]; + public readonly addedForeignKeys: readonly ForeignKeyConstraint[]; + public readonly droppedForeignKeys: readonly ForeignKeyConstraint[]; + + constructor( + public readonly oldTable: Table, + public readonly newTable: Table, + options: TableDiffOptions = {}, + ) { + this.addedColumns = options.addedColumns ?? []; + this.changedColumns = options.changedColumns ?? []; + this.droppedColumns = options.droppedColumns ?? []; + this.addedIndexes = options.addedIndexes ?? []; + this.droppedIndexes = options.droppedIndexes ?? []; + this.addedForeignKeys = options.addedForeignKeys ?? []; + this.droppedForeignKeys = options.droppedForeignKeys ?? []; + } + + public hasChanges(): boolean { + return ( + this.addedColumns.length > 0 || + this.changedColumns.length > 0 || + this.droppedColumns.length > 0 || + this.addedIndexes.length > 0 || + this.droppedIndexes.length > 0 || + this.addedForeignKeys.length > 0 || + this.droppedForeignKeys.length > 0 + ); + } +} diff --git a/src/schema/table-editor.ts b/src/schema/table-editor.ts new file mode 100644 index 0000000..e49b9cb --- /dev/null +++ b/src/schema/table-editor.ts @@ -0,0 +1,101 @@ +import { Column } from "./column"; +import { InvalidTableDefinition } from "./exception/index"; +import { ForeignKeyConstraint } from "./foreign-key-constraint"; +import { Index } from "./index"; +import { PrimaryKeyConstraint } from "./primary-key-constraint"; +import { Table } from "./table"; +import { UniqueConstraint } from "./unique-constraint"; + +export class TableEditor { + private name: string | null = null; + private columns: Column[] = []; + private indexes: Index[] = []; + private primaryKeyConstraint: PrimaryKeyConstraint | null = null; + private uniqueConstraints: UniqueConstraint[] = []; + private foreignKeyConstraints: ForeignKeyConstraint[] = []; + private options: Record = {}; + + public setName(name: string): this { + this.name = name; + return this; + } + + public setColumns(...columns: Column[]): this { + this.columns = [...columns]; + return this; + } + + public addColumn(column: Column): this { + this.columns.push(column); + return this; + } + + public setIndexes(...indexes: Index[]): this { + this.indexes = [...indexes]; + return this; + } + + public addIndex(index: Index): this { + this.indexes.push(index); + return this; + } + + public setPrimaryKeyConstraint(primaryKeyConstraint: PrimaryKeyConstraint | null): this { + this.primaryKeyConstraint = primaryKeyConstraint; + return this; + } + + public setUniqueConstraints(...uniqueConstraints: UniqueConstraint[]): this { + this.uniqueConstraints = [...uniqueConstraints]; + return this; + } + + public addUniqueConstraint(uniqueConstraint: UniqueConstraint): this { + this.uniqueConstraints.push(uniqueConstraint); + return this; + } + + public setForeignKeyConstraints(...foreignKeyConstraints: ForeignKeyConstraint[]): this { + this.foreignKeyConstraints = [...foreignKeyConstraints]; + return this; + } + + public addForeignKeyConstraint(foreignKeyConstraint: ForeignKeyConstraint): this { + this.foreignKeyConstraints.push(foreignKeyConstraint); + return this; + } + + public setOptions(options: Record): this { + this.options = { ...options }; + return this; + } + + public create(): Table { + if (this.name === null) { + throw InvalidTableDefinition.nameNotSet(); + } + + if (this.columns.length === 0) { + throw InvalidTableDefinition.columnsNotSet(this.name); + } + + const table = new Table(this.name, this.columns, this.indexes, this.foreignKeyConstraints, { + ...this.options, + }); + + if (this.primaryKeyConstraint !== null) { + const indexName = this.primaryKeyConstraint.getObjectName() ?? undefined; + table.setPrimaryKey(this.primaryKeyConstraint.getColumnNames(), indexName); + } + + for (const uniqueConstraint of this.uniqueConstraints) { + table.addUniqueIndex( + uniqueConstraint.getColumnNames(), + uniqueConstraint.getObjectName() || undefined, + uniqueConstraint.getOptions(), + ); + } + + return table; + } +} diff --git a/src/schema/table.ts b/src/schema/table.ts new file mode 100644 index 0000000..4b46abe --- /dev/null +++ b/src/schema/table.ts @@ -0,0 +1,272 @@ +import { Type } from "../types/type"; +import { AbstractAsset } from "./abstract-asset"; +import type { ColumnOptions } from "./column"; +import { Column } from "./column"; +import { + ColumnAlreadyExists, + ColumnDoesNotExist, + ForeignKeyDoesNotExist, + IndexAlreadyExists, + IndexDoesNotExist, + InvalidState, + PrimaryKeyAlreadyExists, +} from "./exception/index"; +import { ForeignKeyConstraint } from "./foreign-key-constraint"; +import { Index } from "./index"; +import { TableEditor } from "./table-editor"; + +export class Table extends AbstractAsset { + private readonly columns: Record = {}; + private readonly indexes: Record = {}; + private readonly foreignKeys: Record = {}; + private options: Record; + private primaryKeyName: string | null = null; + + constructor( + name: string, + columns: Column[] = [], + indexes: Index[] = [], + foreignKeys: ForeignKeyConstraint[] = [], + options: Record = {}, + ) { + super(name); + this.options = { ...options }; + + for (const column of columns) { + this.columns[getAssetKey(column.getName())] = column; + } + + for (const index of indexes) { + this.addIndexObject(index); + } + + for (const foreignKey of foreignKeys) { + this.addForeignKeyObject(foreignKey); + } + } + + public addColumn(name: string, type: string | Type, options: ColumnOptions = {}): Column { + if (this.hasColumn(name)) { + throw ColumnAlreadyExists.new(this.getName(), name); + } + + const column = new Column(name, type, options); + this.columns[getAssetKey(name)] = column; + return column; + } + + public changeColumn(name: string, options: ColumnOptions): Column { + const column = this.getColumn(name); + column.setOptions(options); + return column; + } + + public hasColumn(name: string): boolean { + return Object.hasOwn(this.columns, getAssetKey(name)); + } + + public getColumn(name: string): Column { + const key = getAssetKey(name); + const column = this.columns[key]; + + if (column === undefined) { + throw ColumnDoesNotExist.new(name, this.getName()); + } + + return column; + } + + public getColumns(): Column[] { + return Object.values(this.columns); + } + + public dropColumn(name: string): void { + delete this.columns[getAssetKey(name)]; + } + + public addIndex( + columnNames: string[], + indexName?: string, + flags: string[] = [], + options: Record = {}, + ): Index { + const name = indexName ?? this._generateIdentifierName(columnNames, "idx", 30); + const index = new Index(name, columnNames, false, false, flags, options); + this.addIndexObject(index); + return index; + } + + public addUniqueIndex( + columnNames: string[], + indexName?: string, + options: Record = {}, + ): Index { + const name = indexName ?? this._generateIdentifierName(columnNames, "uniq", 30); + const index = new Index(name, columnNames, true, false, [], options); + this.addIndexObject(index); + return index; + } + + public setPrimaryKey(columnNames: string[], indexName?: string): Index { + if (this.hasPrimaryKey()) { + throw PrimaryKeyAlreadyExists.new(this.getName()); + } + + const name = indexName ?? this._generateIdentifierName(columnNames, "primary", 30); + const index = new Index(name, columnNames, true, true); + this.primaryKeyName = name; + this.addIndexObject(index); + return index; + } + + public hasPrimaryKey(): boolean { + return this.primaryKeyName !== null && this.hasIndex(this.primaryKeyName); + } + + public getPrimaryKey(): Index { + if (this.primaryKeyName === null) { + throw InvalidState.tableHasInvalidPrimaryKeyConstraint(this.getName()); + } + + return this.getIndex(this.primaryKeyName); + } + + public getPrimaryKeyColumns(): string[] { + if (!this.hasPrimaryKey()) { + return []; + } + + return this.getPrimaryKey().getColumns(); + } + + public addIndexObject(index: Index): void { + const key = getAssetKey(index.getName()); + if (Object.hasOwn(this.indexes, key)) { + throw IndexAlreadyExists.new(index.getName(), this.getName()); + } + + this.indexes[key] = index; + + if (index.isPrimary()) { + this.primaryKeyName = index.getName(); + } + } + + public hasIndex(name: string): boolean { + return Object.hasOwn(this.indexes, getAssetKey(name)); + } + + public getIndex(name: string): Index { + const key = getAssetKey(name); + const index = this.indexes[key]; + + if (index === undefined) { + throw IndexDoesNotExist.new(name, this.getName()); + } + + return index; + } + + public getIndexes(): Index[] { + return Object.values(this.indexes); + } + + public dropIndex(name: string): void { + if (!this.hasIndex(name)) { + throw IndexDoesNotExist.new(name, this.getName()); + } + + if (this.primaryKeyName !== null && getAssetKey(this.primaryKeyName) === getAssetKey(name)) { + this.primaryKeyName = null; + } + + delete this.indexes[getAssetKey(name)]; + } + + public addForeignKeyConstraint( + foreignTableName: string, + localColumnNames: string[], + foreignColumnNames: string[], + options: Record = {}, + name?: string, + ): ForeignKeyConstraint { + const constraintName = name ?? this._generateIdentifierName(localColumnNames, "fk", 30); + const foreignKey = new ForeignKeyConstraint( + localColumnNames, + foreignTableName, + foreignColumnNames, + constraintName, + options, + this.getName(), + ); + + this.addForeignKeyObject(foreignKey); + return foreignKey; + } + + public addForeignKeyObject(foreignKey: ForeignKeyConstraint): void { + const key = getAssetKey(foreignKey.getName()); + this.foreignKeys[key] = foreignKey; + } + + public hasForeignKey(name: string): boolean { + return Object.hasOwn(this.foreignKeys, getAssetKey(name)); + } + + public getForeignKey(name: string): ForeignKeyConstraint { + const key = getAssetKey(name); + const foreignKey = this.foreignKeys[key]; + + if (foreignKey === undefined) { + throw ForeignKeyDoesNotExist.new(name, this.getName()); + } + + return foreignKey; + } + + public getForeignKeys(): ForeignKeyConstraint[] { + return Object.values(this.foreignKeys); + } + + public removeForeignKey(name: string): void { + if (!this.hasForeignKey(name)) { + throw ForeignKeyDoesNotExist.new(name, this.getName()); + } + + delete this.foreignKeys[getAssetKey(name)]; + } + + public addOption(name: string, value: unknown): this { + this.options[name] = value; + return this; + } + + public hasOption(name: string): boolean { + return Object.hasOwn(this.options, name); + } + + public getOption(name: string): unknown { + return this.options[name]; + } + + public getOptions(): Record { + return { ...this.options }; + } + + public static editor(): TableEditor { + return new TableEditor(); + } + + public edit(): TableEditor { + return Table.editor() + .setName(this.getName()) + .setColumns(...this.getColumns()) + .setIndexes(...this.getIndexes()) + .setForeignKeyConstraints(...this.getForeignKeys()) + .setOptions(this.getOptions()); + } +} + +function getAssetKey(name: string): string { + return name.toLowerCase(); +} diff --git a/src/schema/unique-constraint-editor.ts b/src/schema/unique-constraint-editor.ts new file mode 100644 index 0000000..5165479 --- /dev/null +++ b/src/schema/unique-constraint-editor.ts @@ -0,0 +1,32 @@ +import { UniqueConstraint } from "./unique-constraint"; + +export class UniqueConstraintEditor { + private name: string | null = null; + private columnNames: string[] = []; + private flags: string[] = []; + private options: Record = {}; + + public setName(name: string | null): this { + this.name = name; + return this; + } + + public setColumnNames(...columnNames: string[]): this { + this.columnNames = [...columnNames]; + return this; + } + + public setFlags(...flags: string[]): this { + this.flags = [...flags]; + return this; + } + + public setOptions(options: Record): this { + this.options = { ...options }; + return this; + } + + public create(): UniqueConstraint { + return new UniqueConstraint(this.name ?? "", this.columnNames, this.flags, this.options); + } +} diff --git a/src/schema/unique-constraint.ts b/src/schema/unique-constraint.ts new file mode 100644 index 0000000..5459a5a --- /dev/null +++ b/src/schema/unique-constraint.ts @@ -0,0 +1,90 @@ +import type { AbstractPlatform } from "../platforms/abstract-platform"; +import { InvalidUniqueConstraintDefinition } from "./exception/index"; +import { Identifier } from "./identifier"; +import { UniqueConstraintEditor } from "./unique-constraint-editor"; + +export class UniqueConstraint { + private readonly columns: Identifier[]; + private readonly flags = new Set(); + + constructor( + private readonly name: string, + columns: string[], + flags: string[] = [], + private readonly options: Record = {}, + ) { + if (columns.length === 0) { + throw InvalidUniqueConstraintDefinition.columnNamesAreNotSet(name); + } + + this.columns = columns.map((column) => new Identifier(column)); + for (const flag of flags) { + this.flags.add(flag.toLowerCase()); + } + } + + public getObjectName(): string { + return this.name; + } + + public getColumnNames(): string[] { + return this.columns.map((column) => column.getName()); + } + + public getColumns(): string[] { + return this.getColumnNames(); + } + + public getQuotedColumns(platform: AbstractPlatform): string[] { + return this.columns.map((column) => column.getQuotedName(platform)); + } + + public getUnquotedColumns(): string[] { + return this.columns.map((column) => column.getName().replaceAll(/[`"[\]]/g, "")); + } + + public isClustered(): boolean { + return this.hasFlag("clustered"); + } + + public addFlag(flag: string): this { + this.flags.add(flag.toLowerCase()); + return this; + } + + public hasFlag(flag: string): boolean { + return this.flags.has(flag.toLowerCase()); + } + + public removeFlag(flag: string): void { + this.flags.delete(flag.toLowerCase()); + } + + public getFlags(): string[] { + return [...this.flags]; + } + + public hasOption(name: string): boolean { + return Object.hasOwn(this.options, name.toLowerCase()); + } + + public getOption(name: string): unknown { + return this.options[name.toLowerCase()]; + } + + public getOptions(): Record { + return { ...this.options }; + } + + public static editor(): UniqueConstraintEditor { + return new UniqueConstraintEditor(); + } + + public edit(): UniqueConstraintEditor { + return UniqueConstraint.editor() + .setName(this.name) + .setColumnNames(...this.getColumnNames()) + .setFlags(...this.getFlags()) + .setOptions(this.getOptions()); + } +} diff --git a/src/schema/view-editor.ts b/src/schema/view-editor.ts new file mode 100644 index 0000000..97f28c9 --- /dev/null +++ b/src/schema/view-editor.ts @@ -0,0 +1,34 @@ +import { InvalidViewDefinition } from "./exception/index"; +import { View } from "./view"; + +export class ViewEditor { + private name: string | null = null; + private sql = ""; + + public setName(name: string): this { + this.name = name; + return this; + } + + public setQuotedName(name: string, schemaName: string | null = null): this { + this.name = schemaName === null ? `"${name}"` : `"${schemaName}"."${name}"`; + return this; + } + + public setSQL(sql: string): this { + this.sql = sql; + return this; + } + + public create(): View { + if (this.name === null) { + throw InvalidViewDefinition.nameNotSet(); + } + + if (this.sql.length === 0) { + throw InvalidViewDefinition.sqlNotSet(this.name); + } + + return new View(this.name, this.sql); + } +} diff --git a/src/schema/view.ts b/src/schema/view.ts new file mode 100644 index 0000000..355226c --- /dev/null +++ b/src/schema/view.ts @@ -0,0 +1,23 @@ +import { AbstractAsset } from "./abstract-asset"; +import { ViewEditor } from "./view-editor"; + +export class View extends AbstractAsset { + constructor( + name: string, + private sql: string, + ) { + super(name); + } + + public getSql(): string { + return this.sql; + } + + public static editor(): ViewEditor { + return new ViewEditor(); + } + + public edit(): ViewEditor { + return View.editor().setName(this.getName()).setSQL(this.sql); + } +} diff --git a/src/sql/index.ts b/src/sql/index.ts new file mode 100644 index 0000000..7503776 --- /dev/null +++ b/src/sql/index.ts @@ -0,0 +1,8 @@ +export { DefaultSelectSQLBuilder } from "./builder/default-select-sql-builder"; +export { DefaultUnionSQLBuilder } from "./builder/default-union-sql-builder"; +export type { SelectSQLBuilder } from "./builder/select-sql-builder"; +export type { UnionSQLBuilder } from "./builder/union-sql-builder"; +export { WithSQLBuilder } from "./builder/with-sql-builder"; +export type { SQLParser, Visitor, Visitor as SQLParserVisitor } from "./parser"; +export { Parser, ParserException, RegularExpressionException } from "./parser"; +export { Exception as SQLParserException } from "./parser/exception"; diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..d91bc51 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,2 @@ +export type { DsnConnectionParams, DsnSchemeMapping, DsnSchemeMappingValue } from "./dsn-parser"; +export { DsnParser } from "./dsn-parser"; diff --git a/tsup.config.ts b/tsup.config.ts index 13d330b..1c486b5 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,19 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: { + index: "src/index.ts", + driver: "src/driver/index.ts", + exception: "src/exception/index.ts", + logging: "src/logging/index.ts", + platforms: "src/platforms/index.ts", + portability: "src/portability/index.ts", + query: "src/query/index.ts", + schema: "src/schema/module.ts", + sql: "src/sql/index.ts", + tools: "src/tools/index.ts", + types: "src/types/index.ts", + }, clean: true, format: ["cjs", "esm"], dts: true, From 57e03ca316c1c92450854275e501d3c7593e9a40 Mon Sep 17 00:00:00 2001 From: bernard-ng Date: Mon, 23 Feb 2026 09:11:32 +0200 Subject: [PATCH 03/24] feat: document parity matrix --- CHANGELOG.md | 1 + README.md | 1 + docs/architecture.md | 7 +++-- docs/configuration.md | 2 +- docs/introduction.md | 4 +-- docs/parity-matrix.md | 72 +++++++++++++++++++++++++++++++++++++++++++ docs/platforms.md | 5 +-- docs/portability.md | 4 ++- docs/types.md | 9 +++--- 9 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 docs/parity-matrix.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 41078a0..8e28ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Updated build configuration to emit multi-entry bundles/types for subpath exports. - Added coverage test for namespace barrels and `package.json` subpath export declarations. - Breaking: reduced root `@devscast/datazen` exports to modules backed by files directly under `src/`; grouped APIs now require subpath imports (for example `@devscast/datazen/query`, `@devscast/datazen/types`, `@devscast/datazen/platforms`). +- Documentation: corrected outdated schema/keyword-list status notes and added a living parity matrix guide (`docs/parity-matrix.md`). # 1.0.2 - Schema Foundation Parity - Ported a Doctrine-inspired schema foundation under `src/schema/*`: diff --git a/README.md b/README.md index cc45c15..fdd0ccf 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ bun add @devscast/datazen mssql - [Transactions](docs/transactions.md) - [Security](docs/security.md) - [Known Vendor Issues](docs/known-vendor-issues.md) +- [Parity Matrix (Best Effort)](docs/parity-matrix.md) - [Supporting Other Databases](docs/supporting-other-databases.md) ## Quick Start (MySQL) diff --git a/docs/architecture.md b/docs/architecture.md index c8929d6..c913771 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,6 +117,7 @@ Implemented tooling currently includes: Not Implemented --------------- -The Doctrine DBAL Schema subsystem is not ported in this project yet. -That includes schema introspection, schema manager operations, and schema -tooling/migrations-related APIs. +Full Doctrine DBAL parity is not complete in this project yet. +Major gaps include wider driver coverage, cache/result-cache integration, and +some transaction/retryability APIs. Schema support exists, but parity is still +in progress across all Doctrine features and vendors. diff --git a/docs/configuration.md b/docs/configuration.md index 32aa87c..0fe052f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -208,5 +208,5 @@ in this port. Not Implemented --------------- -- Schema module / schema manager configuration +- Some Doctrine schema-manager options and wrapper customization patterns outside this port's API shape - Doctrine-style wrapper class replacement (`wrapperClass`) diff --git a/docs/introduction.md b/docs/introduction.md index 56d1a23..2ac3a0c 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -39,9 +39,9 @@ Current scope includes: - Driver middleware (logging, portability) - DSN parsing -Current non-goal: +Current limitations: -- Doctrine-style Schema module (schema manager/tooling) is not implemented yet. +- Full Doctrine DBAL Schema parity is not complete yet (especially broader reverse-engineering parity and migrations-adjacent workflows). Getting Started --------------- diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md new file mode 100644 index 0000000..7b119fd --- /dev/null +++ b/docs/parity-matrix.md @@ -0,0 +1,72 @@ +Parity Matrix (Best Effort) +=========================== + +This document tracks high-level parity between DataZen and Doctrine DBAL. +It is intentionally pragmatic: it highlights what is implemented, partially +ported, or still missing in this TypeScript/Node port. + +Legend +------ + +- Implemented: usable and covered in the port (may still differ in API shape for TS/Node) +- Partial: core support exists, but Doctrine breadth/behavior parity is incomplete +- Missing: not implemented in the port yet + +Top-Level Parity +---------------- + +| Area | Status | Notes | +| --- | --- | --- | +| Connection / Statement / Result | Partial | Core runtime APIs are implemented; some Doctrine transaction APIs and behaviors remain missing. | +| DriverManager | Partial | Built-in resolution exists, but driver matrix is much smaller than Doctrine. | +| Driver abstraction | Partial | TS/Node async contract differs intentionally from Doctrine's low-level driver interfaces. | +| Query Builder | Partial | Core builder and execution APIs implemented; Doctrine result cache integration is missing. | +| SQL Parser / SQL Builders | Partial | Parameter parser and SQL builder support exist; parity breadth is still evolving. | +| Platforms | Partial | MySQL, SQL Server, Oracle, Db2 platforms exist; version-specific platform variants/detection are incomplete. | +| Types | Partial | Strong runtime type system and registry support; parity breadth and schema-driven flows continue to evolve. | +| Schema | Partial | Significant schema module support exists (assets, managers, comparator/editors, metadata/introspection scaffolding), but full Doctrine parity is still in progress. | +| Logging middleware | Implemented | Doctrine-inspired middleware pattern ported for Node driver wrapping. | +| Portability middleware | Implemented | Result portability normalization and optimization flags are available. | +| Tools (DSN parser) | Implemented | `DsnParser` exists and is documented/test-covered. | +| Cache subsystem | Missing | Doctrine cache/result-cache integration surfaces are not ported yet. | + +Doctrine Areas With Strong Coverage (Current) +--------------------------------------------- + +- Driver middleware (`logging`, `portability`) +- SQL parameter parsing and array/list expansion flow +- QueryBuilder core operations and execution helpers +- Platform SQL helpers and vendor keyword list access +- Types registry and built-in type conversions +- Schema foundations (assets, diffs, editors, schema managers, metadata/introspection helpers) + +Known Major Gaps vs Doctrine DBAL +--------------------------------- + +- Wider driver support (Doctrine supports many more drivers/vendors than the current `mysql2` + `mssql` runtime adapters) +- Version-specific platform subclass selection and automatic server-version detection +- QueryBuilder result cache integration (`enableResultCache()`-style API) +- Connection transaction isolation getter/setter parity +- Retryable exception marker semantics / lock-wait-timeout-specific exception parity +- Doctrine cache namespace/subsystem parity + +Intentional TS/Node Deviations +------------------------------ + +- Async driver contracts returning `Promise` values +- Node-driver adapters (`mysql2`, `mssql`) instead of PDO-style drivers +- Package subpath exports for grouped APIs: + - `@devscast/datazen/driver` + - `@devscast/datazen/platforms` + - `@devscast/datazen/query` + - `@devscast/datazen/schema` + - `@devscast/datazen/sql` + - `@devscast/datazen/tools` + - `@devscast/datazen/types` +- Root package exports intentionally limited to top-level `src/*.ts` modules + +Notes +----- + +- This is a living document and should be updated when new parity features land. +- For contributor-oriented implementation details and source paths, see `docs/supporting-other-databases.md`. diff --git a/docs/platforms.md b/docs/platforms.md index a1c4402..4376873 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -149,5 +149,6 @@ same public DBAL API. Not Implemented --------------- -The schema module is out of scope currently, so schema-manager-driven platform -features from Doctrine docs are not part of this port yet. +Schema-manager-driven platform features are available in this port, but full +Doctrine parity is still incomplete across vendors and version-specific +platform variants. diff --git a/docs/portability.md b/docs/portability.md index a9aaefc..c73d501 100644 --- a/docs/portability.md +++ b/docs/portability.md @@ -82,7 +82,9 @@ Keyword Lists ------------- Doctrine exposes vendor keyword lists through schema-related APIs. -In this port, schema/keyword-list modules are not implemented yet. +In this port, keyword lists are available through platform APIs such as +`platform.getReservedKeywordsList()` and the `@devscast/datazen/platforms` +namespace keyword classes. Related Modules --------------- diff --git a/docs/types.md b/docs/types.md index 35ad38a..a943657 100644 --- a/docs/types.md +++ b/docs/types.md @@ -200,8 +200,9 @@ including: - `TypeNotFound` - `TypeNotRegistered` -Not Implemented ---------------- +Scope Note +---------- -The schema module is intentionally out of scope in this project at this stage, -so Doctrine-style schema reverse-engineering workflows are not documented here. +Schema support is available separately under `@devscast/datazen/schema`. +This page focuses on the runtime type system rather than schema +reverse-engineering workflows. From d2847efc523349025651a39c060f68798a305481 Mon Sep 17 00:00:00 2001 From: bernard-ng Date: Mon, 23 Feb 2026 10:38:38 +0200 Subject: [PATCH 04/24] feat: add support for pg and sqlite3 drivers --- CHANGELOG.md | 2 + bun.lock | 7 +- package.json | 8 +- .../connection-data-manipulation.test.ts | 5 + ...database-platform-version-provider.test.ts | 124 ++++++++++ .../connection-exception-conversion.test.ts | 5 + .../connection-parameter-compilation.test.ts | 5 + .../connection/connection-transaction.test.ts | 5 + .../connection/connection-typed-fetch.test.ts | 5 + .../static-server-version-provider.test.ts | 11 + src/__tests__/driver/driver-manager.test.ts | 17 +- src/__tests__/driver/mysql2-driver.test.ts | 52 +++++ src/__tests__/driver/pg-connection.test.ts | 147 ++++++++++++ src/__tests__/driver/pg-driver.test.ts | 79 +++++++ .../driver/sqlite3-connection.test.ts | 139 ++++++++++++ src/__tests__/driver/sqlite3-driver.test.ts | 70 ++++++ src/__tests__/logging/middleware.test.ts | 5 + src/__tests__/portability/middleware.test.ts | 11 +- .../schema/schema-file-parity.test.ts | 73 ------ src/__tests__/tools/dsn-parser.test.ts | 22 +- src/connection.ts | 33 ++- .../static-server-version-provider.ts | 9 + src/driver-manager.ts | 6 +- src/driver.ts | 2 +- src/driver/api/pgsql/exception-converter.ts | 126 +++++++++++ src/driver/api/sqlite/exception-converter.ts | 100 +++++++++ src/driver/index.ts | 21 ++ src/driver/mssql/driver.ts | 3 +- src/driver/mysql2/driver.ts | 73 +++++- src/driver/pg/connection.ts | 211 ++++++++++++++++++ src/driver/pg/driver.ts | 58 +++++ src/driver/pg/exception-converter.ts | 3 + src/driver/pg/types.ts | 30 +++ src/driver/sqlite3/connection.ts | 198 ++++++++++++++++ src/driver/sqlite3/driver.ts | 36 +++ src/driver/sqlite3/exception-converter.ts | 3 + src/driver/sqlite3/types.ts | 26 +++ src/index.ts | 1 + src/logging/driver.ts | 8 +- .../exception/invalid-platform-version.ts | 15 ++ src/platforms/index.ts | 10 + src/platforms/mariadb-platform.ts | 9 + src/platforms/mariadb1010-platform.ts | 3 + src/platforms/mariadb1052-platform.ts | 3 + src/platforms/mariadb1060-platform.ts | 9 + src/platforms/mariadb110700-platform.ts | 9 + src/platforms/mysql80-platform.ts | 15 ++ src/platforms/mysql84-platform.ts | 9 + src/platforms/postgre-sql-platform.ts | 86 +++++++ src/platforms/postgre-sql120-platform.ts | 3 + src/platforms/sqlite-platform.ts | 84 +++++++ src/portability/driver.ts | 15 +- src/server-version-provider.ts | 2 +- src/static-server-version-provider.ts | 1 + src/tools/dsn-parser.ts | 11 +- 55 files changed, 1904 insertions(+), 119 deletions(-) create mode 100644 src/__tests__/connection/connection-database-platform-version-provider.test.ts create mode 100644 src/__tests__/connection/static-server-version-provider.test.ts create mode 100644 src/__tests__/driver/pg-connection.test.ts create mode 100644 src/__tests__/driver/pg-driver.test.ts create mode 100644 src/__tests__/driver/sqlite3-connection.test.ts create mode 100644 src/__tests__/driver/sqlite3-driver.test.ts delete mode 100644 src/__tests__/schema/schema-file-parity.test.ts create mode 100644 src/connection/static-server-version-provider.ts create mode 100644 src/driver/api/pgsql/exception-converter.ts create mode 100644 src/driver/api/sqlite/exception-converter.ts create mode 100644 src/driver/pg/connection.ts create mode 100644 src/driver/pg/driver.ts create mode 100644 src/driver/pg/exception-converter.ts create mode 100644 src/driver/pg/types.ts create mode 100644 src/driver/sqlite3/connection.ts create mode 100644 src/driver/sqlite3/driver.ts create mode 100644 src/driver/sqlite3/exception-converter.ts create mode 100644 src/driver/sqlite3/types.ts create mode 100644 src/platforms/exception/invalid-platform-version.ts create mode 100644 src/platforms/mariadb-platform.ts create mode 100644 src/platforms/mariadb1010-platform.ts create mode 100644 src/platforms/mariadb1052-platform.ts create mode 100644 src/platforms/mariadb1060-platform.ts create mode 100644 src/platforms/mariadb110700-platform.ts create mode 100644 src/platforms/mysql80-platform.ts create mode 100644 src/platforms/mysql84-platform.ts create mode 100644 src/platforms/postgre-sql-platform.ts create mode 100644 src/platforms/postgre-sql120-platform.ts create mode 100644 src/platforms/sqlite-platform.ts create mode 100644 src/static-server-version-provider.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e28ad1..4fd0b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # @devscast/datazen # Unreleased +- Breaking: made `Driver.getDatabasePlatform(versionProvider)` driver-owned and required, removed `Connection` driver-name platform fallbacks, added Doctrine-style `StaticServerVersionProvider` selection from `serverVersion` / `primary.serverVersion`, introduced semver-based MySQL/MariaDB versioned platform resolution (`MySQL80/84`, `MariaDB1052/1060/1010/110700`), and now throw `InvalidPlatformVersion` for malformed platform version strings. +- Added `pg` and `sqlite3` driver adapters (connections, exception converters, driver-manager registration, and driver barrel exports) with best-effort Doctrine-style PostgreSQL/SQLite platform classes and DSN scheme aliases (`postgres*` -> `pg`, `sqlite` -> `sqlite3`). - Added package subpath namespace exports so consumers can import grouped APIs from: - `@devscast/datazen/driver` - `@devscast/datazen/exception` diff --git a/bun.lock b/bun.lock index ac1dbb7..1876fd3 100644 --- a/bun.lock +++ b/bun.lock @@ -1,9 +1,11 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "datazend-ts", + "dependencies": { + "semver": "^7.7.4", + }, "devDependencies": { "@biomejs/biome": "^2.4.2", "@changesets/cli": "^2.29.8", @@ -11,6 +13,7 @@ "@commitlint/config-conventional": "^20.4.1", "@types/bun": "latest", "@types/node": "^25.2.3", + "@types/semver": "^7.7.1", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "commitizen": "^4.3.1", @@ -321,6 +324,8 @@ "@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg=="], diff --git a/package.json b/package.json index 789c5e1..ab20b32 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@commitlint/config-conventional": "^20.4.1", "@types/bun": "latest", "@types/node": "^25.2.3", + "@types/semver": "^7.7.1", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "commitizen": "^4.3.1", @@ -89,6 +90,8 @@ "peerDependencies": { "mssql": "^12.2.0", "mysql2": "^3.17.2", + "pg": "^8.11.5", + "sqlite3": "^5.1.7", "typescript": "^5.9.3" }, "scripts": { @@ -133,5 +136,8 @@ "bugs": { "url": "https://github.com/devscast/datazen-ts/issues" }, - "homepage": "https://github.com/devscast/datazen-ts#readme" + "homepage": "https://github.com/devscast/datazen-ts#readme", + "dependencies": { + "semver": "^7.7.4" + } } diff --git a/src/__tests__/connection/connection-data-manipulation.test.ts b/src/__tests__/connection/connection-data-manipulation.test.ts index 7c5ac28..05647ff 100644 --- a/src/__tests__/connection/connection-data-manipulation.test.ts +++ b/src/__tests__/connection/connection-data-manipulation.test.ts @@ -14,6 +14,7 @@ import type { } from "../../driver/api/exception-converter"; import { DriverException } from "../../exception/index"; import { ParameterType } from "../../parameter-type"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { @@ -71,6 +72,10 @@ class PositionalSpyDriver implements Driver { public getExceptionConverter(): ExceptionConverter { return this.converter; } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } } class NamedSpyDriver extends PositionalSpyDriver { diff --git a/src/__tests__/connection/connection-database-platform-version-provider.test.ts b/src/__tests__/connection/connection-database-platform-version-provider.test.ts new file mode 100644 index 0000000..fc44364 --- /dev/null +++ b/src/__tests__/connection/connection-database-platform-version-provider.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { Connection } from "../../connection"; +import { + type Driver, + type DriverConnection, + type DriverExecutionResult, + type DriverQueryResult, + ParameterBindingStyle, +} from "../../driver"; +import type { + ExceptionConverter, + ExceptionConverterContext, +} from "../../driver/api/exception-converter"; +import { DriverException } from "../../exception/index"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; +import type { ServerVersionProvider } from "../../server-version-provider"; +import { StaticServerVersionProvider } from "../../static-server-version-provider"; +import type { CompiledQuery } from "../../types"; + +class NoopExceptionConverter implements ExceptionConverter { + public convert(error: unknown, context: ExceptionConverterContext): DriverException { + return new DriverException("driver error", { + cause: error, + driverName: "platform-spy", + operation: context.operation, + parameters: context.query?.parameters, + sql: context.query?.sql, + }); + } +} + +class PlatformSpyConnection implements DriverConnection { + public async executeQuery(_query: CompiledQuery): Promise { + return { rows: [] }; + } + + public async executeStatement(_query: CompiledQuery): Promise { + return { affectedRows: 0 }; + } + + public async beginTransaction(): Promise {} + + public async commit(): Promise {} + + public async rollBack(): Promise {} + + public async getServerVersion(): Promise { + return "driver-connection-version"; + } + + public async close(): Promise {} + + public getNativeConnection(): unknown { + return this; + } +} + +class PlatformSpyDriver implements Driver { + public readonly name = "platform-spy"; + public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; + public capturedVersionProvider: ServerVersionProvider | null = null; + private readonly converter = new NoopExceptionConverter(); + private readonly platform = new MySQLPlatform(); + + public async connect(_params: Record): Promise { + return new PlatformSpyConnection(); + } + + public getExceptionConverter(): ExceptionConverter { + return this.converter; + } + + public getDatabasePlatform(versionProvider: ServerVersionProvider): MySQLPlatform { + this.capturedVersionProvider = versionProvider; + return this.platform; + } +} + +describe("Connection database platform version provider resolution", () => { + it("passes the connection as version provider when no serverVersion is configured", () => { + const driver = new PlatformSpyDriver(); + const connection = new Connection({}, driver); + + connection.getDatabasePlatform(); + + expect(driver.capturedVersionProvider).toBe(connection); + }); + + it("uses top-level serverVersion when provided", async () => { + const driver = new PlatformSpyDriver(); + const connection = new Connection( + { + primary: { serverVersion: "8.0.10" }, + serverVersion: "8.0.36", + }, + driver, + ); + + connection.getDatabasePlatform(); + + expect(driver.capturedVersionProvider).toBeInstanceOf(StaticServerVersionProvider); + await expect(Promise.resolve(driver.capturedVersionProvider?.getServerVersion())).resolves.toBe( + "8.0.36", + ); + }); + + it("falls back to primary.serverVersion when top-level serverVersion is absent", async () => { + const driver = new PlatformSpyDriver(); + const connection = new Connection( + { + primary: { serverVersion: "5.7.42" }, + }, + driver, + ); + + connection.getDatabasePlatform(); + + expect(driver.capturedVersionProvider).toBeInstanceOf(StaticServerVersionProvider); + await expect(Promise.resolve(driver.capturedVersionProvider?.getServerVersion())).resolves.toBe( + "5.7.42", + ); + }); +}); diff --git a/src/__tests__/connection/connection-exception-conversion.test.ts b/src/__tests__/connection/connection-exception-conversion.test.ts index 531ca71..87cff4c 100644 --- a/src/__tests__/connection/connection-exception-conversion.test.ts +++ b/src/__tests__/connection/connection-exception-conversion.test.ts @@ -13,6 +13,7 @@ import type { ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ConnectionException, DbalException, DriverException } from "../../exception/index"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; import type { CompiledQuery } from "../../types"; class SpyExceptionConverter implements ExceptionConverter { @@ -115,6 +116,10 @@ class SpyDriver implements Driver { public getExceptionConverter(): ExceptionConverter { return this.converter; } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } } describe("Connection exception conversion", () => { diff --git a/src/__tests__/connection/connection-parameter-compilation.test.ts b/src/__tests__/connection/connection-parameter-compilation.test.ts index 7ced7d8..b4bca5c 100644 --- a/src/__tests__/connection/connection-parameter-compilation.test.ts +++ b/src/__tests__/connection/connection-parameter-compilation.test.ts @@ -14,6 +14,7 @@ import type { } from "../../driver/api/exception-converter"; import { DriverException, MixedParameterStyleException } from "../../exception/index"; import { ParameterType } from "../../parameter-type"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { @@ -66,6 +67,10 @@ class NamedSpyDriver implements Driver { public getExceptionConverter(): ExceptionConverter { return this.converter; } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } } describe("Connection parameter compilation", () => { diff --git a/src/__tests__/connection/connection-transaction.test.ts b/src/__tests__/connection/connection-transaction.test.ts index 7343783..7b8f14a 100644 --- a/src/__tests__/connection/connection-transaction.test.ts +++ b/src/__tests__/connection/connection-transaction.test.ts @@ -19,6 +19,7 @@ import { NoActiveTransactionException, RollbackOnlyException, } from "../../exception/index"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { @@ -146,6 +147,10 @@ class SpyDriver implements Driver { public getExceptionConverter(): ExceptionConverter { return this.exceptionConverter; } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } } describe("Connection transactions and state", () => { diff --git a/src/__tests__/connection/connection-typed-fetch.test.ts b/src/__tests__/connection/connection-typed-fetch.test.ts index c7a2c82..c283786 100644 --- a/src/__tests__/connection/connection-typed-fetch.test.ts +++ b/src/__tests__/connection/connection-typed-fetch.test.ts @@ -13,6 +13,7 @@ import type { ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { DriverException } from "../../exception/index"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { @@ -64,6 +65,10 @@ class StaticRowsDriver implements Driver { public getExceptionConverter(): ExceptionConverter { return this.converter; } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } } function expectUserRow(_row: { id: number; name: string } | false): void {} diff --git a/src/__tests__/connection/static-server-version-provider.test.ts b/src/__tests__/connection/static-server-version-provider.test.ts new file mode 100644 index 0000000..044fa1e --- /dev/null +++ b/src/__tests__/connection/static-server-version-provider.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { StaticServerVersionProvider } from "../../static-server-version-provider"; + +describe("StaticServerVersionProvider", () => { + it("returns the configured server version", async () => { + const provider = new StaticServerVersionProvider("8.0.36"); + + await expect(Promise.resolve(provider.getServerVersion())).resolves.toBe("8.0.36"); + }); +}); diff --git a/src/__tests__/driver/driver-manager.test.ts b/src/__tests__/driver/driver-manager.test.ts index 345917d..0e7b491 100644 --- a/src/__tests__/driver/driver-manager.test.ts +++ b/src/__tests__/driver/driver-manager.test.ts @@ -19,6 +19,7 @@ import { DriverRequiredException, UnknownDriverException, } from "../../exception/index"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { @@ -68,6 +69,10 @@ class SpyDriver implements Driver { public getExceptionConverter(): ExceptionConverter { return this.converter; } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } } class NeverUseDriver implements Driver { @@ -81,6 +86,10 @@ class NeverUseDriver implements Driver { public getExceptionConverter(): ExceptionConverter { return new NoopExceptionConverter(); } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } } class PrefixMiddleware implements DriverMiddleware { @@ -92,6 +101,7 @@ class PrefixMiddleware implements DriverMiddleware { return { bindingStyle: driver.bindingStyle, connect: (params: Record) => driver.connect(params), + getDatabasePlatform: (versionProvider) => driver.getDatabasePlatform(versionProvider), getExceptionConverter: () => driver.getExceptionConverter(), name: `${prefix}${driver.name}`, }; @@ -100,7 +110,12 @@ class PrefixMiddleware implements DriverMiddleware { describe("DriverManager", () => { it("lists available drivers", () => { - expect(DriverManager.getAvailableDrivers().sort()).toEqual(["mssql", "mysql2"]); + expect(DriverManager.getAvailableDrivers().sort()).toEqual([ + "mssql", + "mysql2", + "pg", + "sqlite3", + ]); }); it("throws when no driver is configured", () => { diff --git a/src/__tests__/driver/mysql2-driver.test.ts b/src/__tests__/driver/mysql2-driver.test.ts index 3966e98..6fe5eba 100644 --- a/src/__tests__/driver/mysql2-driver.test.ts +++ b/src/__tests__/driver/mysql2-driver.test.ts @@ -3,6 +3,14 @@ import { describe, expect, it } from "vitest"; import { ParameterBindingStyle } from "../../driver"; import { MySQL2Driver } from "../../driver/mysql2/driver"; import { DbalException } from "../../exception/index"; +import { InvalidPlatformVersion } from "../../platforms/exception/invalid-platform-version"; +import { MariaDB1010Platform } from "../../platforms/mariadb1010-platform"; +import { MariaDB1052Platform } from "../../platforms/mariadb1052-platform"; +import { MariaDB110700Platform } from "../../platforms/mariadb110700-platform"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; +import { MySQL80Platform } from "../../platforms/mysql80-platform"; +import { MySQL84Platform } from "../../platforms/mysql84-platform"; +import { StaticServerVersionProvider } from "../../static-server-version-provider"; describe("MySQL2Driver", () => { it("exposes expected metadata", () => { @@ -86,4 +94,48 @@ describe("MySQL2Driver", () => { expect(driver.getExceptionConverter()).toBe(driver.getExceptionConverter()); }); + + it("resolves MySQL platform variants from static versions", () => { + const driver = new MySQL2Driver(); + + expect(driver.getDatabasePlatform(new StaticServerVersionProvider("8.0.36"))).toBeInstanceOf( + MySQL80Platform, + ); + expect(driver.getDatabasePlatform(new StaticServerVersionProvider("8.4.2"))).toBeInstanceOf( + MySQL84Platform, + ); + expect(driver.getDatabasePlatform(new StaticServerVersionProvider("5.7.44"))).toBeInstanceOf( + MySQLPlatform, + ); + }); + + it("resolves MariaDB platform variants from static versions", () => { + const driver = new MySQL2Driver(); + + expect( + driver.getDatabasePlatform(new StaticServerVersionProvider("10.10.2-MariaDB-1:10.10.2")), + ).toBeInstanceOf(MariaDB1010Platform); + expect( + driver.getDatabasePlatform(new StaticServerVersionProvider("11.7.1-MariaDB-ubu2404")), + ).toBeInstanceOf(MariaDB110700Platform); + expect( + driver.getDatabasePlatform(new StaticServerVersionProvider("5.5.5-MariaDB-10.5.21")), + ).toBeInstanceOf(MariaDB1052Platform); + }); + + it("throws InvalidPlatformVersion for malformed MariaDB versions", () => { + const driver = new MySQL2Driver(); + + expect(() => + driver.getDatabasePlatform(new StaticServerVersionProvider("mariadb-not-a-version")), + ).toThrow(InvalidPlatformVersion); + }); + + it("throws InvalidPlatformVersion for malformed MySQL versions", () => { + const driver = new MySQL2Driver(); + + expect(() => + driver.getDatabasePlatform(new StaticServerVersionProvider("totally-invalid-version")), + ).toThrow(InvalidPlatformVersion); + }); }); diff --git a/src/__tests__/driver/pg-connection.test.ts b/src/__tests__/driver/pg-connection.test.ts new file mode 100644 index 0000000..e42434b --- /dev/null +++ b/src/__tests__/driver/pg-connection.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; + +import { PgConnection } from "../../driver/pg/connection"; +import type { PgQueryResultLike } from "../../driver/pg/types"; +import { DbalException, InvalidParameterException } from "../../exception/index"; + +interface LoggedCall { + parameters?: unknown[]; + sql: string; +} + +class FakePgClient { + public readonly calls: LoggedCall[] = []; + public released = 0; + + constructor( + private readonly handler: (sql: string, parameters?: unknown[]) => Promise, + ) {} + + public async query(sql: string, parameters?: unknown[]): Promise { + this.calls.push({ parameters, sql }); + return this.handler(sql, parameters); + } + + public release(): void { + this.released += 1; + } +} + +class FakePgPool { + public readonly calls: LoggedCall[] = []; + public endCalls = 0; + + constructor( + private readonly handler: (sql: string, parameters?: unknown[]) => Promise, + private readonly txClient: FakePgClient, + ) {} + + public async query(sql: string, parameters?: unknown[]): Promise { + this.calls.push({ parameters, sql }); + return this.handler(sql, parameters); + } + + public async connect(): Promise { + return this.txClient; + } + + public async end(): Promise { + this.endCalls += 1; + } +} + +describe("PgConnection", () => { + it("rewrites positional placeholders and normalizes query results", async () => { + const connection = new PgConnection( + new FakePgClient(async (_sql, _params) => ({ + fields: [{ name: "id" }, { name: "name" }], + rowCount: 1, + rows: [{ id: 1, name: "Alice" }], + })), + false, + ); + + const result = await connection.executeQuery({ + parameters: [1, "active"], + sql: "SELECT id, name FROM users WHERE id = ? AND status = ?", + types: [], + }); + + expect(result).toEqual({ + columns: ["id", "name"], + rowCount: 1, + rows: [{ id: 1, name: "Alice" }], + }); + const native = connection.getNativeConnection() as FakePgClient; + expect(native.calls[0]).toEqual({ + parameters: [1, "active"], + sql: "SELECT id, name FROM users WHERE id = $1 AND status = $2", + }); + }); + + it("rejects named parameter payloads after compilation", async () => { + const connection = new PgConnection(new FakePgClient(async () => ({ rows: [] })), false); + + await expect( + connection.executeQuery({ + parameters: { id: 1 }, + sql: "SELECT * FROM users WHERE id = :id", + types: {}, + }), + ).rejects.toThrow(InvalidParameterException); + }); + + it("uses a dedicated pooled client for transactions and releases it", async () => { + const txClient = new FakePgClient(async () => ({ rowCount: 0, rows: [] })); + const pool = new FakePgPool(async () => ({ rowCount: 0, rows: [] }), txClient); + const connection = new PgConnection(pool, false); + + await connection.beginTransaction(); + await connection.executeStatement({ + parameters: [1], + sql: "UPDATE users SET active = ?", + types: [], + }); + await connection.commit(); + + expect(txClient.calls.map((call) => call.sql)).toEqual([ + "BEGIN", + "UPDATE users SET active = $1", + "COMMIT", + ]); + expect(txClient.released).toBe(1); + }); + + it("supports savepoints, quoting and server version lookup", async () => { + const client = new FakePgClient(async (sql) => { + if (sql === "SHOW server_version") { + return { rows: [{ server_version: "16.2" }] }; + } + + return { rowCount: 0, rows: [] }; + }); + const connection = new PgConnection(client, false); + + await connection.createSavepoint("sp1"); + await connection.releaseSavepoint("sp1"); + await connection.rollbackSavepoint("sp1"); + + expect(client.calls.slice(0, 3).map((call) => call.sql)).toEqual([ + "SAVEPOINT sp1", + "RELEASE SAVEPOINT sp1", + "ROLLBACK TO SAVEPOINT sp1", + ]); + expect(connection.quote("O'Reilly")).toBe("'O''Reilly'"); + await expect(connection.getServerVersion()).resolves.toBe("16.2"); + }); + + it("throws on invalid transaction transitions", async () => { + const connection = new PgConnection( + new FakePgClient(async () => ({ rowCount: 0, rows: [] })), + false, + ); + + await expect(connection.commit()).rejects.toThrow(DbalException); + await expect(connection.rollBack()).rejects.toThrow(DbalException); + }); +}); diff --git a/src/__tests__/driver/pg-driver.test.ts b/src/__tests__/driver/pg-driver.test.ts new file mode 100644 index 0000000..304557a --- /dev/null +++ b/src/__tests__/driver/pg-driver.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { ParameterBindingStyle } from "../../driver"; +import { PgDriver } from "../../driver/pg/driver"; +import { DbalException } from "../../exception/index"; +import { InvalidPlatformVersion } from "../../platforms/exception/invalid-platform-version"; +import { PostgreSQLPlatform } from "../../platforms/postgre-sql-platform"; +import { PostgreSQL120Platform } from "../../platforms/postgre-sql120-platform"; +import { StaticServerVersionProvider } from "../../static-server-version-provider"; + +describe("PgDriver", () => { + it("exposes expected metadata", () => { + const driver = new PgDriver(); + + expect(driver.name).toBe("pg"); + expect(driver.bindingStyle).toBe(ParameterBindingStyle.POSITIONAL); + }); + + it("throws when no client object is provided", async () => { + const driver = new PgDriver(); + + await expect(driver.connect({})).rejects.toThrow(DbalException); + }); + + it("prefers pool over connection/client in params", async () => { + const driver = new PgDriver(); + const pool = { query: async () => ({ rows: [] }) }; + const connection = { query: async () => ({ rows: [] }) }; + const client = { query: async () => ({ rows: [] }) }; + + const driverConnection = await driver.connect({ + client, + connection, + pool, + }); + + expect(driverConnection.getNativeConnection()).toBe(pool); + }); + + it("closes owned clients when configured", async () => { + const calls = { end: 0 }; + const pool = { + end: async () => { + calls.end += 1; + }, + query: async () => ({ rows: [] }), + }; + + const driverConnection = await new PgDriver().connect({ + ownsPool: true, + pool, + }); + + await driverConnection.close(); + expect(calls.end).toBe(1); + }); + + it("returns a stable exception converter instance", () => { + const driver = new PgDriver(); + expect(driver.getExceptionConverter()).toBe(driver.getExceptionConverter()); + }); + + it("returns PostgreSQL platform variants from server version", () => { + const driver = new PgDriver(); + const platform = driver.getDatabasePlatform(new StaticServerVersionProvider("11.22")); + const platform120 = driver.getDatabasePlatform(new StaticServerVersionProvider("16.2")); + + expect(platform).toBeInstanceOf(PostgreSQLPlatform); + expect(platform120).toBeInstanceOf(PostgreSQL120Platform); + }); + + it("throws InvalidPlatformVersion for malformed PostgreSQL versions", () => { + const driver = new PgDriver(); + + expect(() => + driver.getDatabasePlatform(new StaticServerVersionProvider("not-a-postgres-version")), + ).toThrow(InvalidPlatformVersion); + }); +}); diff --git a/src/__tests__/driver/sqlite3-connection.test.ts b/src/__tests__/driver/sqlite3-connection.test.ts new file mode 100644 index 0000000..3f43ecf --- /dev/null +++ b/src/__tests__/driver/sqlite3-connection.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { SQLite3Connection } from "../../driver/sqlite3/connection"; +import { DbalException, InvalidParameterException } from "../../exception/index"; + +class FakeSQLiteDatabase { + public readonly allCalls: Array<{ parameters: unknown[]; sql: string }> = []; + public readonly runCalls: Array<{ parameters: unknown[]; sql: string }> = []; + public readonly execCalls: string[] = []; + public closeCalls = 0; + + constructor( + private readonly allHandler: (sql: string, parameters: unknown[]) => unknown[] = () => [], + private readonly runHandler: ( + sql: string, + parameters: unknown[], + ) => { changes?: number; lastID?: number } = () => ({ changes: 0 }), + ) {} + + public all( + sql: string, + parameters: unknown[], + callback: (error: Error | null, rows?: unknown[]) => void, + ): void { + this.allCalls.push({ parameters, sql }); + callback(null, this.allHandler(sql, parameters)); + } + + public run( + sql: string, + parameters: unknown[], + callback?: (this: { changes?: number; lastID?: number }, error: Error | null) => void, + ): void { + this.runCalls.push({ parameters, sql }); + const ctx = this.runHandler(sql, parameters); + callback?.call(ctx, null); + } + + public exec(sql: string, callback: (error: Error | null) => void): void { + this.execCalls.push(sql); + callback(null); + } + + public close(callback: (error: Error | null) => void): void { + this.closeCalls += 1; + callback(null); + } +} + +describe("SQLite3Connection", () => { + it("executes queries and normalizes rows", async () => { + const db = new FakeSQLiteDatabase(() => [{ id: 1, name: "Alice" }]); + const connection = new SQLite3Connection(db, false); + + const result = await connection.executeQuery({ + parameters: [1], + sql: "SELECT id, name FROM users WHERE id = ?", + types: [], + }); + + expect(result).toEqual({ + columns: ["id", "name"], + rowCount: 1, + rows: [{ id: 1, name: "Alice" }], + }); + }); + + it("executes statements and returns changes/insertId", async () => { + const db = new FakeSQLiteDatabase( + () => [], + () => ({ changes: 2, lastID: 7 }), + ); + const connection = new SQLite3Connection(db, false); + + const result = await connection.executeStatement({ + parameters: ["active"], + sql: "UPDATE users SET status = ?", + types: [], + }); + + expect(result).toEqual({ affectedRows: 2, insertId: 7 }); + }); + + it("rejects named parameter payloads after compilation", async () => { + const connection = new SQLite3Connection(new FakeSQLiteDatabase(), false); + + await expect( + connection.executeQuery({ + parameters: { id: 1 }, + sql: "SELECT * FROM users WHERE id = :id", + types: {}, + }), + ).rejects.toThrow(InvalidParameterException); + }); + + it("supports transactions and savepoints via exec()", async () => { + const db = new FakeSQLiteDatabase(); + const connection = new SQLite3Connection(db, false); + + await connection.beginTransaction(); + await connection.createSavepoint("sp1"); + await connection.releaseSavepoint("sp1"); + await connection.rollbackSavepoint("sp1"); + await connection.commit(); + + expect(db.execCalls).toEqual([ + "BEGIN", + "SAVEPOINT sp1", + "RELEASE SAVEPOINT sp1", + "ROLLBACK TO SAVEPOINT sp1", + "COMMIT", + ]); + }); + + it("quotes values and reads sqlite version", async () => { + const db = new FakeSQLiteDatabase((sql) => { + if (sql.includes("sqlite_version")) { + return [{ version: 3.45 }]; + } + + return []; + }); + const connection = new SQLite3Connection(db, false); + + expect(connection.quote("O'Reilly")).toBe("'O''Reilly'"); + await expect(connection.getServerVersion()).resolves.toBe("3.45"); + }); + + it("throws on invalid transaction transitions and closes owned databases", async () => { + const db = new FakeSQLiteDatabase(); + const connection = new SQLite3Connection(db, true); + + await expect(connection.commit()).rejects.toThrow(DbalException); + await expect(connection.rollBack()).rejects.toThrow(DbalException); + + await connection.close(); + expect(db.closeCalls).toBe(1); + }); +}); diff --git a/src/__tests__/driver/sqlite3-driver.test.ts b/src/__tests__/driver/sqlite3-driver.test.ts new file mode 100644 index 0000000..a330558 --- /dev/null +++ b/src/__tests__/driver/sqlite3-driver.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { ParameterBindingStyle } from "../../driver"; +import { SQLite3Driver } from "../../driver/sqlite3/driver"; +import { DbalException } from "../../exception/index"; +import { SQLitePlatform } from "../../platforms/sqlite-platform"; +import { StaticServerVersionProvider } from "../../static-server-version-provider"; + +describe("SQLite3Driver", () => { + it("exposes expected metadata", () => { + const driver = new SQLite3Driver(); + + expect(driver.name).toBe("sqlite3"); + expect(driver.bindingStyle).toBe(ParameterBindingStyle.POSITIONAL); + }); + + it("throws when no database object is provided", async () => { + const driver = new SQLite3Driver(); + + await expect(driver.connect({})).rejects.toThrow(DbalException); + }); + + it("prefers database over connection/client", async () => { + const driver = new SQLite3Driver(); + const database = { all: () => undefined, run: () => undefined }; + const connection = { all: () => undefined, run: () => undefined }; + const client = { all: () => undefined, run: () => undefined }; + + const driverConnection = await driver.connect({ + client, + connection, + database, + }); + + expect(driverConnection.getNativeConnection()).toBe(database); + }); + + it("closes owned databases when configured", async () => { + const calls = { close: 0 }; + const database = { + all: (_sql: string, _params: unknown[], cb: (e: Error | null, rows?: unknown[]) => void) => + cb(null, []), + close: (cb: (e: Error | null) => void) => { + calls.close += 1; + cb(null); + }, + run: ( + _sql: string, + _params: unknown[], + cb?: (this: { changes: number; lastID: number }, e: Error | null) => void, + ) => cb?.call({ changes: 0, lastID: 0 }, null), + }; + + const connection = await new SQLite3Driver().connect({ client: database, ownsClient: true }); + await connection.close(); + expect(calls.close).toBe(1); + }); + + it("returns a stable exception converter instance", () => { + const driver = new SQLite3Driver(); + expect(driver.getExceptionConverter()).toBe(driver.getExceptionConverter()); + }); + + it("returns the SQLite platform", () => { + const platform = new SQLite3Driver().getDatabasePlatform( + new StaticServerVersionProvider("3.45.1"), + ); + expect(platform).toBeInstanceOf(SQLitePlatform); + }); +}); diff --git a/src/__tests__/logging/middleware.test.ts b/src/__tests__/logging/middleware.test.ts index b4e2928..7c06b52 100644 --- a/src/__tests__/logging/middleware.test.ts +++ b/src/__tests__/logging/middleware.test.ts @@ -17,6 +17,7 @@ import { DriverException } from "../../exception/index"; import type { Logger } from "../../logging/logger"; import { Middleware } from "../../logging/middleware"; import { ParameterType } from "../../parameter-type"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; import type { CompiledQuery } from "../../types"; interface LogEntry { @@ -135,6 +136,10 @@ class SpyDriver implements Driver { public getExceptionConverter(): ExceptionConverter { return this.converter; } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } } describe("Logging Middleware", () => { diff --git a/src/__tests__/portability/middleware.test.ts b/src/__tests__/portability/middleware.test.ts index c1db754..8a82d2f 100644 --- a/src/__tests__/portability/middleware.test.ts +++ b/src/__tests__/portability/middleware.test.ts @@ -16,6 +16,7 @@ import type { import { DriverManager } from "../../driver-manager"; import { DriverException } from "../../exception/index"; import { OraclePlatform } from "../../platforms/oracle-platform"; +import { SQLServerPlatform } from "../../platforms/sql-server-platform"; import { Connection } from "../../portability/connection"; import { Middleware } from "../../portability/middleware"; import type { CompiledQuery } from "../../types"; @@ -62,16 +63,14 @@ class SpyConnection implements DriverConnection { class SpyDriver implements Driver { public readonly name = "spy"; public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; - public readonly getDatabasePlatform?: () => OraclePlatform; private readonly converter = new NoopExceptionConverter(); + private readonly platform: OraclePlatform | null; constructor( private readonly connection: SpyConnection, platform?: OraclePlatform, ) { - if (platform !== undefined) { - this.getDatabasePlatform = () => platform; - } + this.platform = platform ?? null; } public async connect(_params: Record): Promise { @@ -81,6 +80,10 @@ class SpyDriver implements Driver { public getExceptionConverter(): ExceptionConverter { return this.converter; } + + public getDatabasePlatform() { + return this.platform ?? new SQLServerPlatform(); + } } describe("Portability Middleware", () => { diff --git a/src/__tests__/schema/schema-file-parity.test.ts b/src/__tests__/schema/schema-file-parity.test.ts deleted file mode 100644 index b2dc609..0000000 --- a/src/__tests__/schema/schema-file-parity.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { readdirSync, statSync } from "node:fs"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -function listFiles(root: string, ext: string): string[] { - const out: string[] = []; - - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const absolutePath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - walk(absolutePath); - continue; - } - - if (entry.isFile() && absolutePath.endsWith(ext)) { - out.push(path.relative(root, absolutePath)); - } - } - }; - - walk(root); - return out.sort(); -} - -function normalizeAcronyms(input: string): string { - return input - .replaceAll("MySQL", "Mysql") - .replaceAll("PostgreSQL", "PostgreSql") - .replaceAll("SQLServer", "SqlServer") - .replaceAll("SQLite", "Sqlite") - .replaceAll("DB2", "Db2"); -} - -function toKebab(segment: string): string { - const normalized = normalizeAcronyms(segment); - - return normalized - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") - .replace(/([a-z0-9])([A-Z])/g, "$1-$2") - .replaceAll("_", "-") - .toLowerCase(); -} - -describe("Schema file parity", () => { - it("covers Doctrine Schema file map with best-effort TS parity", () => { - const workspaceRoot = process.cwd(); - const referenceRoot = path.join(workspaceRoot, "references/dbal/src/Schema"); - const targetRoot = path.join(workspaceRoot, "src/schema"); - - if (!statSync(referenceRoot).isDirectory()) { - throw new Error("Reference Doctrine schema folder not found."); - } - - const referenceFiles = listFiles(referenceRoot, ".php").map((relativePath) => { - const withoutExt = relativePath.slice(0, -4); - return withoutExt - .split(path.sep) - .map((segment) => toKebab(segment)) - .join("/"); - }); - - const targetFiles = listFiles(targetRoot, ".ts").map((relativePath) => - relativePath.slice(0, -3), - ); - - const missing = referenceFiles.filter((referenceFile) => !targetFiles.includes(referenceFile)); - - expect(missing).toEqual([]); - }); -}); diff --git a/src/__tests__/tools/dsn-parser.test.ts b/src/__tests__/tools/dsn-parser.test.ts index 184274e..b4fdf9e 100644 --- a/src/__tests__/tools/dsn-parser.test.ts +++ b/src/__tests__/tools/dsn-parser.test.ts @@ -16,6 +16,10 @@ class DummyDriver implements Driver { public getExceptionConverter(): ExceptionConverter { throw new Error("not implemented"); } + + public getDatabasePlatform(): never { + throw new Error("not implemented"); + } } describe("DsnParser", () => { @@ -81,17 +85,31 @@ describe("DsnParser", () => { const file = parser.parse("sqlite:////var/data/app.db"); expect(memory).toEqual({ - driver: "sqlite", + driver: "sqlite3", host: "localhost", memory: true, }); expect(file).toEqual({ - driver: "sqlite", + driver: "sqlite3", host: "localhost", path: "/var/data/app.db", }); }); + it("maps postgres URL schemes to pg driver", () => { + const parser = new DsnParser(); + + const params = parser.parse("postgresql://user:pass@localhost/app"); + + expect(params).toEqual({ + dbname: "app", + driver: "pg", + host: "localhost", + password: "pass", + user: "user", + }); + }); + it("lets query params override parsed params", () => { const parser = new DsnParser(); const params = parser.parse( diff --git a/src/connection.ts b/src/connection.ts index 8d99401..c57d5a2 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -14,8 +14,6 @@ import { import { ExpandArrayParameters } from "./expand-array-parameters"; import { ParameterType } from "./parameter-type"; import { AbstractPlatform } from "./platforms/abstract-platform"; -import { MySQLPlatform } from "./platforms/mysql-platform"; -import { SQLServerPlatform } from "./platforms/sql-server-platform"; import { Query } from "./query"; import { ExpressionBuilder } from "./query/expression/expression-builder"; import { QueryBuilder } from "./query/query-builder"; @@ -23,8 +21,10 @@ import { Result } from "./result"; import type { AbstractSchemaManager } from "./schema/abstract-schema-manager"; import { DefaultSchemaManagerFactory } from "./schema/default-schema-manager-factory"; import type { SchemaManagerFactory } from "./schema/schema-manager-factory"; +import type { ServerVersionProvider } from "./server-version-provider"; import { Parser, type SQLParser, type Visitor } from "./sql/parser"; import { Statement, type StatementExecutor } from "./statement"; +import { StaticServerVersionProvider } from "./static-server-version-provider"; import type { CompiledQuery, QueryParameterType, @@ -681,25 +681,22 @@ export class Connection implements StatementExecutor { return this.databasePlatform; } - const driverPlatform = this.driver.getDatabasePlatform?.(); - if (driverPlatform !== undefined) { - this.databasePlatform = driverPlatform; - return this.databasePlatform; - } - - if (this.driver.name === "mysql2") { - this.databasePlatform = new MySQLPlatform(); - return this.databasePlatform; - } + let versionProvider: ServerVersionProvider = this; - if (this.driver.name === "mssql") { - this.databasePlatform = new SQLServerPlatform(); - return this.databasePlatform; + if (typeof this.params.serverVersion === "string") { + versionProvider = new StaticServerVersionProvider(this.params.serverVersion); + } else { + const primary = this.params.primary; + if (primary !== null && typeof primary === "object") { + const primaryServerVersion = (primary as Record).serverVersion; + if (typeof primaryServerVersion === "string") { + versionProvider = new StaticServerVersionProvider(primaryServerVersion); + } + } } - throw new DbalException( - `No database platform could be resolved for driver "${this.driver.name}".`, - ); + this.databasePlatform = this.driver.getDatabasePlatform(versionProvider); + return this.databasePlatform; } public async connect(): Promise { diff --git a/src/connection/static-server-version-provider.ts b/src/connection/static-server-version-provider.ts new file mode 100644 index 0000000..a90f4b6 --- /dev/null +++ b/src/connection/static-server-version-provider.ts @@ -0,0 +1,9 @@ +import type { ServerVersionProvider } from "../server-version-provider"; + +export class StaticServerVersionProvider implements ServerVersionProvider { + constructor(private readonly version: string) {} + + public getServerVersion(): string { + return this.version; + } +} diff --git a/src/driver-manager.ts b/src/driver-manager.ts index b5aeba0..5c0f512 100644 --- a/src/driver-manager.ts +++ b/src/driver-manager.ts @@ -3,9 +3,11 @@ import { Connection } from "./connection"; import type { Driver } from "./driver"; import { MSSQLDriver } from "./driver/mssql/driver"; import { MySQL2Driver } from "./driver/mysql2/driver"; +import { PgDriver } from "./driver/pg/driver"; +import { SQLite3Driver } from "./driver/sqlite3/driver"; import { DriverRequiredException, UnknownDriverException } from "./exception/index"; -export type DriverName = "mysql2" | "mssql"; +export type DriverName = "mysql2" | "mssql" | "pg" | "sqlite3"; export interface ConnectionParams extends Record { driver?: DriverName; @@ -17,6 +19,8 @@ export class DriverManager { private static readonly DRIVER_MAP: Record Driver> = { mssql: MSSQLDriver, mysql2: MySQL2Driver, + pg: PgDriver, + sqlite3: SQLite3Driver, }; public static getConnection( diff --git a/src/driver.ts b/src/driver.ts index ac9ca8e..ef36796 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -38,7 +38,7 @@ export interface Driver { readonly bindingStyle: ParameterBindingStyle; connect(params: Record): Promise; getExceptionConverter(): ExceptionConverter; - getDatabasePlatform?(): AbstractPlatform; + getDatabasePlatform(versionProvider: ServerVersionProvider): AbstractPlatform; } export interface DriverMiddleware { diff --git a/src/driver/api/pgsql/exception-converter.ts b/src/driver/api/pgsql/exception-converter.ts new file mode 100644 index 0000000..10ae4e7 --- /dev/null +++ b/src/driver/api/pgsql/exception-converter.ts @@ -0,0 +1,126 @@ +import { + ConnectionException, + DeadlockException, + DriverException, + type DriverExceptionDetails, + ForeignKeyConstraintViolationException, + NotNullConstraintViolationException, + SqlSyntaxException, + UniqueConstraintViolationException, +} from "../../../exception/index"; +import type { + ExceptionConverterContext, + ExceptionConverter as ExceptionConverterContract, +} from "../exception-converter"; + +const UNIQUE_CONSTRAINT_SQLSTATES = new Set(["23505"]); +const FOREIGN_KEY_CONSTRAINT_SQLSTATES = new Set(["23503"]); +const NOT_NULL_CONSTRAINT_SQLSTATES = new Set(["23502"]); +const SYNTAX_SQLSTATES = new Set(["42601", "42703", "42P01"]); +const CONNECTION_SQLSTATES = new Set(["57P01", "57P02", "57P03"]); + +export class ExceptionConverter implements ExceptionConverterContract { + public convert(error: unknown, context: ExceptionConverterContext): DriverException { + const details = this.createDetails(error, context); + + if (details.sqlState === "40P01") { + return new DeadlockException(details.message, details); + } + + if (details.sqlState !== undefined && UNIQUE_CONSTRAINT_SQLSTATES.has(details.sqlState)) { + return new UniqueConstraintViolationException(details.message, details); + } + + if (details.sqlState !== undefined && FOREIGN_KEY_CONSTRAINT_SQLSTATES.has(details.sqlState)) { + return new ForeignKeyConstraintViolationException(details.message, details); + } + + if (details.sqlState !== undefined && NOT_NULL_CONSTRAINT_SQLSTATES.has(details.sqlState)) { + return new NotNullConstraintViolationException(details.message, details); + } + + if (details.sqlState !== undefined && SYNTAX_SQLSTATES.has(details.sqlState)) { + return new SqlSyntaxException(details.message, details); + } + + if (this.isConnectionError(details)) { + return new ConnectionException(details.message, details); + } + + return new DriverException(details.message, details); + } + + private createDetails( + error: unknown, + context: ExceptionConverterContext, + ): DriverExceptionDetails & { message: string } { + const record = this.asRecord(error); + const code = this.extractCode(record); + const sqlState = this.extractSqlState(record); + const message = this.extractMessage(error); + + return { + cause: error, + code, + driverName: "pg", + message, + operation: context.operation, + parameters: context.query?.parameters, + sql: context.query?.sql, + sqlState, + }; + } + + private extractCode(record: Record): number | string | undefined { + const code = record.code; + if (typeof code === "string" || typeof code === "number") { + return code; + } + + return undefined; + } + + private extractSqlState(record: Record): string | undefined { + const state = record.code; + if (typeof state === "string") { + return state; + } + + const sqlState = record.sqlState; + if (typeof sqlState === "string") { + return sqlState; + } + + return undefined; + } + + private extractMessage(error: unknown): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return "pg driver error."; + } + + private isConnectionError(details: { code?: number | string; sqlState?: string }): boolean { + if (details.sqlState !== undefined) { + if (details.sqlState.startsWith("08") || CONNECTION_SQLSTATES.has(details.sqlState)) { + return true; + } + } + + if (typeof details.code === "string") { + return details.code.startsWith("ECONN") || details.code === "ETIMEDOUT"; + } + + return false; + } + + private asRecord(value: unknown): Record { + if (value !== null && typeof value === "object") { + return value as Record; + } + + return {}; + } +} diff --git a/src/driver/api/sqlite/exception-converter.ts b/src/driver/api/sqlite/exception-converter.ts new file mode 100644 index 0000000..bd58268 --- /dev/null +++ b/src/driver/api/sqlite/exception-converter.ts @@ -0,0 +1,100 @@ +import { + ConnectionException, + DeadlockException, + DriverException, + type DriverExceptionDetails, + ForeignKeyConstraintViolationException, + NotNullConstraintViolationException, + SqlSyntaxException, + UniqueConstraintViolationException, +} from "../../../exception/index"; +import type { + ExceptionConverterContext, + ExceptionConverter as ExceptionConverterContract, +} from "../exception-converter"; + +export class ExceptionConverter implements ExceptionConverterContract { + public convert(error: unknown, context: ExceptionConverterContext): DriverException { + const details = this.createDetails(error, context); + const code = typeof details.code === "string" ? details.code.toUpperCase() : undefined; + const message = details.message.toLowerCase(); + + if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") { + return new DeadlockException(details.message, details); + } + + if (code === "SQLITE_CANTOPEN") { + return new ConnectionException(details.message, details); + } + + if (code?.startsWith("SQLITE_CONSTRAINT_FOREIGNKEY") === true) { + return new ForeignKeyConstraintViolationException(details.message, details); + } + + if (code?.startsWith("SQLITE_CONSTRAINT_NOTNULL") === true) { + return new NotNullConstraintViolationException(details.message, details); + } + + if ( + code?.startsWith("SQLITE_CONSTRAINT_UNIQUE") === true || + code?.startsWith("SQLITE_CONSTRAINT_PRIMARYKEY") === true + ) { + return new UniqueConstraintViolationException(details.message, details); + } + + if (code === "SQLITE_ERROR" && message.includes("syntax")) { + return new SqlSyntaxException(details.message, details); + } + + return new DriverException(details.message, details); + } + + private createDetails( + error: unknown, + context: ExceptionConverterContext, + ): DriverExceptionDetails & { message: string } { + const record = this.asRecord(error); + const message = this.extractMessage(error); + + return { + cause: error, + code: this.extractCode(record), + driverName: "sqlite3", + message, + operation: context.operation, + parameters: context.query?.parameters, + sql: context.query?.sql, + sqlState: undefined, + }; + } + + private extractCode(record: Record): number | string | undefined { + const code = record.code; + if (typeof code === "string" || typeof code === "number") { + return code; + } + + const errno = record.errno; + if (typeof errno === "number") { + return errno; + } + + return undefined; + } + + private extractMessage(error: unknown): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return "sqlite3 driver error."; + } + + private asRecord(value: unknown): Record { + if (value !== null && typeof value === "object") { + return value as Record; + } + + return {}; + } +} diff --git a/src/driver/index.ts b/src/driver/index.ts index 1f4c3f3..a469b47 100644 --- a/src/driver/index.ts +++ b/src/driver/index.ts @@ -7,6 +7,8 @@ export type { } from "../driver"; export { ParameterBindingStyle } from "../driver"; export { ExceptionConverter as MySQLExceptionConverter } from "./api/mysql/exception-converter"; +export { ExceptionConverter as PgSQLExceptionConverter } from "./api/pgsql/exception-converter"; +export { ExceptionConverter as SQLiteExceptionConverter } from "./api/sqlite/exception-converter"; export { ExceptionConverter as SQLSrvExceptionConverter } from "./api/sqlsrv/exception-converter"; export type { ExceptionConverter, ExceptionConverterContext } from "./exception-converter"; export { MSSQLConnection } from "./mssql/connection"; @@ -27,3 +29,22 @@ export type { MySQL2ExecutorLike, MySQL2PoolLike, } from "./mysql2/types"; +export { PgConnection } from "./pg/connection"; +export { PgDriver } from "./pg/driver"; +export { PgExceptionConverter } from "./pg/exception-converter"; +export type { + PgConnectionParams, + PgFieldLike, + PgPoolClientLike, + PgPoolLike, + PgQueryResultLike, + PgQueryableLike, +} from "./pg/types"; +export { SQLite3Connection } from "./sqlite3/connection"; +export { SQLite3Driver } from "./sqlite3/driver"; +export { SQLite3ExceptionConverter } from "./sqlite3/exception-converter"; +export type { + SQLite3ConnectionParams, + SQLite3DatabaseLike, + SQLite3RunContextLike, +} from "./sqlite3/types"; diff --git a/src/driver/mssql/driver.ts b/src/driver/mssql/driver.ts index adafaf3..d462674 100644 --- a/src/driver/mssql/driver.ts +++ b/src/driver/mssql/driver.ts @@ -1,6 +1,7 @@ import { type Driver, type DriverConnection, ParameterBindingStyle } from "../../driver"; import { DbalException } from "../../exception/index"; import { SQLServerPlatform } from "../../platforms/sql-server-platform"; +import type { ServerVersionProvider } from "../../server-version-provider"; import { ExceptionConverter as SQLSrvExceptionConverter } from "../api/sqlsrv/exception-converter"; import { MSSQLConnection } from "./connection"; import type { MSSQLConnectionParams } from "./types"; @@ -29,7 +30,7 @@ export class MSSQLDriver implements Driver { return this.exceptionConverter; } - public getDatabasePlatform(): SQLServerPlatform { + public getDatabasePlatform(_versionProvider: ServerVersionProvider): SQLServerPlatform { return this.platform; } } diff --git a/src/driver/mysql2/driver.ts b/src/driver/mysql2/driver.ts index 7d6756a..8ac11a9 100644 --- a/src/driver/mysql2/driver.ts +++ b/src/driver/mysql2/driver.ts @@ -1,6 +1,18 @@ +import { coerce, gte } from "semver"; + import { type Driver, type DriverConnection, ParameterBindingStyle } from "../../driver"; import { DbalException } from "../../exception/index"; +import { AbstractMySQLPlatform } from "../../platforms/abstract-mysql-platform"; +import { InvalidPlatformVersion } from "../../platforms/exception/invalid-platform-version"; +import { MariaDBPlatform } from "../../platforms/mariadb-platform"; +import { MariaDB1010Platform } from "../../platforms/mariadb1010-platform"; +import { MariaDB1052Platform } from "../../platforms/mariadb1052-platform"; +import { MariaDB1060Platform } from "../../platforms/mariadb1060-platform"; +import { MariaDB110700Platform } from "../../platforms/mariadb110700-platform"; import { MySQLPlatform } from "../../platforms/mysql-platform"; +import { MySQL80Platform } from "../../platforms/mysql80-platform"; +import { MySQL84Platform } from "../../platforms/mysql84-platform"; +import { type ServerVersionProvider } from "../../server-version-provider"; import { ExceptionConverter as MySQLExceptionConverter } from "../api/mysql/exception-converter"; import { MySQL2Connection } from "./connection"; import type { MySQL2ConnectionParams } from "./types"; @@ -9,7 +21,6 @@ export class MySQL2Driver implements Driver { public readonly name = "mysql2"; public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; private readonly exceptionConverter = new MySQLExceptionConverter(); - private readonly platform = new MySQLPlatform(); public async connect(params: Record): Promise { const connectionParams = params as MySQL2ConnectionParams; @@ -29,7 +40,63 @@ export class MySQL2Driver implements Driver { return this.exceptionConverter; } - public getDatabasePlatform(): MySQLPlatform { - return this.platform; + public getDatabasePlatform(versionProvider: ServerVersionProvider): AbstractMySQLPlatform { + const version = versionProvider.getServerVersion(); + + if (typeof version !== "string") { + // Best effort parity: async providers can't be consumed from this sync API. + return new MySQLPlatform(); + } + + if (version.toLowerCase().includes("mariadb")) { + const mariaDbVersion = this.getMariaDbMysqlVersionNumber(version); + if (gte(mariaDbVersion, "11.7.0")) { + return new MariaDB110700Platform(); + } + + if (gte(mariaDbVersion, "10.10.0")) { + return new MariaDB1010Platform(); + } + + if (gte(mariaDbVersion, "10.6.0")) { + return new MariaDB1060Platform(); + } + + if (gte(mariaDbVersion, "10.5.2")) { + return new MariaDB1052Platform(); + } + + return new MariaDBPlatform(); + } + + const mysqlVersion = coerce(version)?.version; + if (mysqlVersion === undefined) { + throw InvalidPlatformVersion.new(version, ".."); + } + + if (mysqlVersion !== undefined && gte(mysqlVersion, "8.4.0")) { + return new MySQL84Platform(); + } + + if (mysqlVersion !== undefined && gte(mysqlVersion, "8.0.0")) { + return new MySQL80Platform(); + } + + return new MySQLPlatform(); + } + + private getMariaDbMysqlVersionNumber(versionString: string): string { + const match = /^(?:5\.5\.5-)?(?:mariadb-)?(?\d+)\.(?\d+)\.(?\d+)/i.exec( + versionString, + ); + + if (match?.groups === undefined) { + throw InvalidPlatformVersion.new( + versionString, + "^(?:5.5.5-)?(mariadb-)?..", + ); + } + + return `${match.groups.major}.${match.groups.minor}.${match.groups.patch}`; } } diff --git a/src/driver/pg/connection.ts b/src/driver/pg/connection.ts new file mode 100644 index 0000000..bd12972 --- /dev/null +++ b/src/driver/pg/connection.ts @@ -0,0 +1,211 @@ +import type { DriverConnection, DriverExecutionResult, DriverQueryResult } from "../../driver"; +import { DbalException, InvalidParameterException } from "../../exception/index"; +import { Parser, type Visitor } from "../../sql/parser"; +import type { CompiledQuery } from "../../types"; +import type { PgPoolClientLike, PgPoolLike, PgQueryResultLike, PgQueryableLike } from "./types"; + +export class PgConnection implements DriverConnection { + private readonly parser = new Parser(false); + private transactionClient: PgPoolClientLike | null = null; + private inTransaction = false; + + constructor( + private readonly client: PgPoolLike | PgPoolClientLike, + private readonly ownsClient: boolean, + ) {} + + public async executeQuery(query: CompiledQuery): Promise { + const parameters = this.toPositionalParameters(query.parameters); + const sql = this.convertPositionalPlaceholders(query.sql); + const payload = await this.getQueryable().query(sql, parameters); + const rows = this.toRows(payload); + const firstRow = rows[0]; + + return { + columns: this.toColumns(payload, firstRow), + rowCount: typeof payload.rowCount === "number" ? payload.rowCount : rows.length, + rows, + }; + } + + public async executeStatement(query: CompiledQuery): Promise { + const parameters = this.toPositionalParameters(query.parameters); + const sql = this.convertPositionalPlaceholders(query.sql); + const payload = await this.getQueryable().query(sql, parameters); + + return { + affectedRows: typeof payload.rowCount === "number" ? payload.rowCount : 0, + insertId: null, + }; + } + + public async beginTransaction(): Promise { + if (this.inTransaction) { + throw new DbalException("A transaction is already active on this connection."); + } + + if (this.transactionClient === null && this.isPool(this.client)) { + this.transactionClient = await this.client.connect(); + } + + await this.getQueryable().query("BEGIN"); + this.inTransaction = true; + } + + public async commit(): Promise { + if (!this.inTransaction) { + throw new DbalException("No active transaction to commit."); + } + + try { + await this.getQueryable().query("COMMIT"); + } finally { + this.inTransaction = false; + this.releaseTransactionClient(); + } + } + + public async rollBack(): Promise { + if (!this.inTransaction) { + throw new DbalException("No active transaction to roll back."); + } + + try { + await this.getQueryable().query("ROLLBACK"); + } finally { + this.inTransaction = false; + this.releaseTransactionClient(); + } + } + + public async createSavepoint(name: string): Promise { + await this.getQueryable().query(`SAVEPOINT ${name}`); + } + + public async releaseSavepoint(name: string): Promise { + await this.getQueryable().query(`RELEASE SAVEPOINT ${name}`); + } + + public async rollbackSavepoint(name: string): Promise { + await this.getQueryable().query(`ROLLBACK TO SAVEPOINT ${name}`); + } + + public quote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; + } + + public async getServerVersion(): Promise { + const result = await this.getQueryable().query("SHOW server_version"); + const rows = this.toRows(result); + const firstRow = rows[0]; + + const version = + firstRow?.server_version ?? firstRow?.serverVersion ?? firstRow?.version ?? "unknown"; + + return typeof version === "string" ? version : String(version); + } + + public async close(): Promise { + if (this.inTransaction) { + try { + await this.getQueryable().query("ROLLBACK"); + } catch { + // best effort rollback during close + } finally { + this.inTransaction = false; + } + } + + this.releaseTransactionClient(); + + if (this.ownsClient && "end" in this.client && typeof this.client.end === "function") { + await this.client.end(); + } + } + + public getNativeConnection(): unknown { + return this.transactionClient ?? this.client; + } + + private getQueryable(): PgQueryableLike { + return this.transactionClient ?? this.client; + } + + private isPool( + client: PgPoolLike | PgPoolClientLike, + ): client is PgPoolLike & { connect: NonNullable } { + return typeof (client as PgPoolLike).connect === "function"; + } + + private releaseTransactionClient(): void { + if (this.transactionClient?.release !== undefined) { + this.transactionClient.release(); + } + + this.transactionClient = null; + } + + private convertPositionalPlaceholders(sql: string): string { + const parts: string[] = []; + let index = 0; + + const visitor: Visitor = { + acceptNamedParameter: (): void => { + throw new InvalidParameterException( + "The pg driver expects positional parameters after SQL compilation.", + ); + }, + acceptOther: (fragment: string): void => { + parts.push(fragment); + }, + acceptPositionalParameter: (): void => { + index += 1; + parts.push(`$${index}`); + }, + }; + + this.parser.parse(sql, visitor); + return parts.join(""); + } + + private toPositionalParameters(parameters: CompiledQuery["parameters"]): unknown[] { + if (Array.isArray(parameters)) { + return parameters; + } + + throw new InvalidParameterException( + "The pg driver expects positional parameters after SQL compilation.", + ); + } + + private toRows(payload: PgQueryResultLike): Array> { + if (!Array.isArray(payload.rows)) { + return []; + } + + const rows: Array> = []; + for (const row of payload.rows) { + if (row !== null && typeof row === "object" && !Array.isArray(row)) { + rows.push(row as Record); + } + } + + return rows; + } + + private toColumns( + payload: PgQueryResultLike, + firstRow: Record | undefined, + ): string[] { + if (Array.isArray(payload.fields)) { + const names = payload.fields + .map((field) => field?.name) + .filter((name): name is string => typeof name === "string"); + if (names.length > 0) { + return names; + } + } + + return firstRow === undefined ? [] : Object.keys(firstRow); + } +} diff --git a/src/driver/pg/driver.ts b/src/driver/pg/driver.ts new file mode 100644 index 0000000..d9bb67b --- /dev/null +++ b/src/driver/pg/driver.ts @@ -0,0 +1,58 @@ +import { type Driver, type DriverConnection, ParameterBindingStyle } from "../../driver"; +import { DbalException } from "../../exception/index"; +import { InvalidPlatformVersion } from "../../platforms/exception/invalid-platform-version"; +import { PostgreSQLPlatform } from "../../platforms/postgre-sql-platform"; +import { PostgreSQL120Platform } from "../../platforms/postgre-sql120-platform"; +import type { ServerVersionProvider } from "../../server-version-provider"; +import { ExceptionConverter as PgSQLExceptionConverter } from "../api/pgsql/exception-converter"; +import { PgConnection } from "./connection"; +import type { PgConnectionParams } from "./types"; + +export class PgDriver implements Driver { + public readonly name = "pg"; + public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; + private readonly exceptionConverter = new PgSQLExceptionConverter(); + private readonly platform = new PostgreSQLPlatform(); + private readonly platform120 = new PostgreSQL120Platform(); + + public async connect(params: Record): Promise { + const connectionParams = params as PgConnectionParams; + const client = connectionParams.pool ?? connectionParams.connection ?? connectionParams.client; + + if (client === undefined) { + throw new DbalException( + "pg connection requires one of `pool`, `connection`, or `client` in connection params.", + ); + } + + const ownsClient = Boolean(connectionParams.ownsPool ?? connectionParams.ownsClient); + return new PgConnection(client, ownsClient); + } + + public getExceptionConverter(): PgSQLExceptionConverter { + return this.exceptionConverter; + } + + public getDatabasePlatform(_versionProvider: ServerVersionProvider): PostgreSQLPlatform { + const version = _versionProvider.getServerVersion(); + if (typeof version !== "string") { + return this.platform; + } + + const match = /^(?\d+)(?:\.(?\d+)(?:\.(?\d+))?)?/.exec(version); + if (match?.groups === undefined) { + throw InvalidPlatformVersion.new(version, ".."); + } + + const major = Number.parseInt(match.groups.major ?? "0", 10); + if (Number.isNaN(major)) { + throw InvalidPlatformVersion.new(version, ".."); + } + + if (major >= 12) { + return this.platform120; + } + + return this.platform; + } +} diff --git a/src/driver/pg/exception-converter.ts b/src/driver/pg/exception-converter.ts new file mode 100644 index 0000000..e0dd3bc --- /dev/null +++ b/src/driver/pg/exception-converter.ts @@ -0,0 +1,3 @@ +import { ExceptionConverter as PgSQLExceptionConverter } from "../api/pgsql/exception-converter"; + +export class PgExceptionConverter extends PgSQLExceptionConverter {} diff --git a/src/driver/pg/types.ts b/src/driver/pg/types.ts new file mode 100644 index 0000000..1fa3542 --- /dev/null +++ b/src/driver/pg/types.ts @@ -0,0 +1,30 @@ +export interface PgFieldLike { + name: string; +} + +export interface PgQueryResultLike { + rows?: unknown[]; + rowCount?: number | null; + fields?: PgFieldLike[]; +} + +export interface PgQueryableLike { + query(sql: string, parameters?: unknown[]): Promise | PgQueryResultLike; +} + +export interface PgPoolClientLike extends PgQueryableLike { + release?(): void; +} + +export interface PgPoolLike extends PgQueryableLike { + connect?(): Promise | PgPoolClientLike; + end?(): Promise | void; +} + +export interface PgConnectionParams extends Record { + client?: PgPoolLike | PgPoolClientLike; + connection?: PgPoolClientLike; + pool?: PgPoolLike; + ownsClient?: boolean; + ownsPool?: boolean; +} diff --git a/src/driver/sqlite3/connection.ts b/src/driver/sqlite3/connection.ts new file mode 100644 index 0000000..c6e0e5c --- /dev/null +++ b/src/driver/sqlite3/connection.ts @@ -0,0 +1,198 @@ +import type { DriverConnection, DriverExecutionResult, DriverQueryResult } from "../../driver"; +import { DbalException, InvalidParameterException } from "../../exception/index"; +import type { CompiledQuery } from "../../types"; +import type { SQLite3DatabaseLike, SQLite3RunContextLike } from "./types"; + +export class SQLite3Connection implements DriverConnection { + private inTransaction = false; + + constructor( + private readonly database: SQLite3DatabaseLike, + private readonly ownsClient: boolean, + ) {} + + public async executeQuery(query: CompiledQuery): Promise { + const parameters = this.toPositionalParameters(query.parameters); + const rows = await this.queryAll(query.sql, parameters); + const firstRow = rows[0]; + + return { + columns: firstRow === undefined ? [] : Object.keys(firstRow), + rowCount: rows.length, + rows, + }; + } + + public async executeStatement(query: CompiledQuery): Promise { + const parameters = this.toPositionalParameters(query.parameters); + const result = await this.queryRun(query.sql, parameters); + + return { + affectedRows: result.changes ?? 0, + insertId: result.lastID ?? null, + }; + } + + public async beginTransaction(): Promise { + if (this.inTransaction) { + throw new DbalException("A transaction is already active on this connection."); + } + + await this.exec("BEGIN"); + this.inTransaction = true; + } + + public async commit(): Promise { + if (!this.inTransaction) { + throw new DbalException("No active transaction to commit."); + } + + await this.exec("COMMIT"); + this.inTransaction = false; + } + + public async rollBack(): Promise { + if (!this.inTransaction) { + throw new DbalException("No active transaction to roll back."); + } + + await this.exec("ROLLBACK"); + this.inTransaction = false; + } + + public async createSavepoint(name: string): Promise { + await this.exec(`SAVEPOINT ${name}`); + } + + public async releaseSavepoint(name: string): Promise { + await this.exec(`RELEASE SAVEPOINT ${name}`); + } + + public async rollbackSavepoint(name: string): Promise { + await this.exec(`ROLLBACK TO SAVEPOINT ${name}`); + } + + public quote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; + } + + public async getServerVersion(): Promise { + const rows = await this.queryAll("SELECT sqlite_version() AS version", []); + const version = rows[0]?.version ?? "unknown"; + + return typeof version === "string" ? version : String(version); + } + + public async close(): Promise { + if (this.inTransaction) { + try { + await this.exec("ROLLBACK"); + } catch { + // best effort rollback during close + } finally { + this.inTransaction = false; + } + } + + if (!this.ownsClient) { + return; + } + + if (this.database.close === undefined) { + return; + } + + await new Promise((resolve, reject) => { + this.database.close?.((error) => { + if (error !== null) { + reject(error); + return; + } + + resolve(); + }); + }); + } + + public getNativeConnection(): unknown { + return this.database; + } + + private toPositionalParameters(parameters: CompiledQuery["parameters"]): unknown[] { + if (Array.isArray(parameters)) { + return parameters; + } + + throw new InvalidParameterException( + "The sqlite3 driver expects positional parameters after SQL compilation.", + ); + } + + private async queryAll( + sql: string, + parameters: unknown[], + ): Promise>> { + if (this.database.all === undefined) { + throw new DbalException("The provided sqlite3 database does not expose all()."); + } + + const rows = await new Promise((resolve, reject) => { + this.database.all?.(sql, parameters, (error, result) => { + if (error !== null) { + reject(error); + return; + } + + resolve(Array.isArray(result) ? result : []); + }); + }); + + const normalized: Array> = []; + for (const row of rows) { + if (row !== null && typeof row === "object" && !Array.isArray(row)) { + normalized.push(row as Record); + } + } + + return normalized; + } + + private async queryRun(sql: string, parameters: unknown[]): Promise { + if (this.database.run === undefined) { + throw new DbalException("The provided sqlite3 database does not expose run()."); + } + + return new Promise((resolve, reject) => { + this.database.run?.(sql, parameters, function (this: SQLite3RunContextLike, error) { + if (error !== null) { + reject(error); + return; + } + + resolve({ + changes: typeof this?.changes === "number" ? this.changes : 0, + lastID: typeof this?.lastID === "number" ? this.lastID : undefined, + }); + }); + }); + } + + private async exec(sql: string): Promise { + if (this.database.exec !== undefined) { + await new Promise((resolve, reject) => { + this.database.exec?.(sql, (error) => { + if (error !== null) { + reject(error); + return; + } + + resolve(); + }); + }); + + return; + } + + await this.queryRun(sql, []); + } +} diff --git a/src/driver/sqlite3/driver.ts b/src/driver/sqlite3/driver.ts new file mode 100644 index 0000000..a8c3ceb --- /dev/null +++ b/src/driver/sqlite3/driver.ts @@ -0,0 +1,36 @@ +import { type Driver, type DriverConnection, ParameterBindingStyle } from "../../driver"; +import { DbalException } from "../../exception/index"; +import { SQLitePlatform } from "../../platforms/sqlite-platform"; +import type { ServerVersionProvider } from "../../server-version-provider"; +import { ExceptionConverter as SQLiteExceptionConverter } from "../api/sqlite/exception-converter"; +import { SQLite3Connection } from "./connection"; +import type { SQLite3ConnectionParams } from "./types"; + +export class SQLite3Driver implements Driver { + public readonly name = "sqlite3"; + public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; + private readonly exceptionConverter = new SQLiteExceptionConverter(); + private readonly platform = new SQLitePlatform(); + + public async connect(params: Record): Promise { + const connectionParams = params as SQLite3ConnectionParams; + const client = + connectionParams.database ?? connectionParams.connection ?? connectionParams.client; + + if (client === undefined) { + throw new DbalException( + "sqlite3 connection requires one of `database`, `connection`, or `client` in connection params.", + ); + } + + return new SQLite3Connection(client, Boolean(connectionParams.ownsClient)); + } + + public getExceptionConverter(): SQLiteExceptionConverter { + return this.exceptionConverter; + } + + public getDatabasePlatform(_versionProvider: ServerVersionProvider): SQLitePlatform { + return this.platform; + } +} diff --git a/src/driver/sqlite3/exception-converter.ts b/src/driver/sqlite3/exception-converter.ts new file mode 100644 index 0000000..bc6ed6a --- /dev/null +++ b/src/driver/sqlite3/exception-converter.ts @@ -0,0 +1,3 @@ +import { ExceptionConverter as SQLiteExceptionConverter } from "../api/sqlite/exception-converter"; + +export class SQLite3ExceptionConverter extends SQLiteExceptionConverter {} diff --git a/src/driver/sqlite3/types.ts b/src/driver/sqlite3/types.ts new file mode 100644 index 0000000..9458f47 --- /dev/null +++ b/src/driver/sqlite3/types.ts @@ -0,0 +1,26 @@ +export interface SQLite3RunContextLike { + changes?: number; + lastID?: number; +} + +export interface SQLite3DatabaseLike { + all?( + sql: string, + parameters: unknown[], + callback: (error: Error | null, rows?: unknown[]) => void, + ): void; + exec?(sql: string, callback: (error: Error | null) => void): void; + run?( + sql: string, + parameters: unknown[], + callback?: (this: SQLite3RunContextLike, error: Error | null) => void, + ): void; + close?(callback: (error: Error | null) => void): void; +} + +export interface SQLite3ConnectionParams extends Record { + client?: SQLite3DatabaseLike; + connection?: SQLite3DatabaseLike; + database?: SQLite3DatabaseLike; + ownsClient?: boolean; +} diff --git a/src/index.ts b/src/index.ts index 0dcf600..a3700cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export { Query } from "./query"; export { Result } from "./result"; export type { ServerVersionProvider } from "./server-version-provider"; export { Statement } from "./statement"; +export { StaticServerVersionProvider } from "./static-server-version-provider"; export { TransactionIsolationLevel } from "./transaction-isolation-level"; export type { CompiledQuery, diff --git a/src/logging/driver.ts b/src/logging/driver.ts index 3f84097..1018698 100644 --- a/src/logging/driver.ts +++ b/src/logging/driver.ts @@ -5,19 +5,19 @@ import { } from "../driver"; import type { ExceptionConverter } from "../driver/api/exception-converter"; import type { AbstractPlatform } from "../platforms/abstract-platform"; +import type { ServerVersionProvider } from "../server-version-provider"; import { Connection } from "./connection"; import type { Logger } from "./logger"; export class Driver implements DriverInterface { - public readonly getDatabasePlatform?: () => AbstractPlatform; + public readonly getDatabasePlatform: (versionProvider: ServerVersionProvider) => AbstractPlatform; constructor( private readonly driver: DriverInterface, private readonly logger: Logger, ) { - if (this.driver.getDatabasePlatform !== undefined) { - this.getDatabasePlatform = (): AbstractPlatform => this.driver.getDatabasePlatform!(); - } + this.getDatabasePlatform = (versionProvider: ServerVersionProvider): AbstractPlatform => + this.driver.getDatabasePlatform(versionProvider); } public get name(): string { diff --git a/src/platforms/exception/invalid-platform-version.ts b/src/platforms/exception/invalid-platform-version.ts new file mode 100644 index 0000000..e0d1e03 --- /dev/null +++ b/src/platforms/exception/invalid-platform-version.ts @@ -0,0 +1,15 @@ +import type { PlatformException } from "./platform-exception"; + +export class InvalidPlatformVersion extends Error implements PlatformException { + constructor(version: string, expectedFormat: string) { + super( + `Invalid platform version "${version}" specified. The platform version has to be specified in the format: "${expectedFormat}".`, + ); + this.name = "InvalidPlatformVersion"; + Object.setPrototypeOf(this, InvalidPlatformVersion.prototype); + } + + public static new(version: string, expectedFormat: string): InvalidPlatformVersion { + return new InvalidPlatformVersion(version, expectedFormat); + } +} diff --git a/src/platforms/index.ts b/src/platforms/index.ts index 8505271..27ed04a 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -16,7 +16,17 @@ export { SQLServerKeywords, SQLiteKeywords, } from "./keywords"; +export { MariaDBPlatform } from "./mariadb-platform"; +export { MariaDB1010Platform } from "./mariadb1010-platform"; +export { MariaDB1052Platform } from "./mariadb1052-platform"; +export { MariaDB1060Platform } from "./mariadb1060-platform"; +export { MariaDB110700Platform } from "./mariadb110700-platform"; export { MySQLPlatform } from "./mysql-platform"; +export { MySQL80Platform } from "./mysql80-platform"; +export { MySQL84Platform } from "./mysql84-platform"; export { OraclePlatform } from "./oracle-platform"; +export { PostgreSQLPlatform } from "./postgre-sql-platform"; +export { PostgreSQL120Platform } from "./postgre-sql120-platform"; export { SQLServerPlatform } from "./sql-server-platform"; +export { SQLitePlatform } from "./sqlite-platform"; export { TrimMode } from "./trim-mode"; diff --git a/src/platforms/mariadb-platform.ts b/src/platforms/mariadb-platform.ts new file mode 100644 index 0000000..b1fb597 --- /dev/null +++ b/src/platforms/mariadb-platform.ts @@ -0,0 +1,9 @@ +import { AbstractMySQLPlatform } from "./abstract-mysql-platform"; +import type { KeywordList } from "./keywords/keyword-list"; +import { MariaDBKeywords } from "./keywords/mariadb-keywords"; + +export class MariaDBPlatform extends AbstractMySQLPlatform { + protected override createReservedKeywordsList(): KeywordList { + return new MariaDBKeywords(); + } +} diff --git a/src/platforms/mariadb1010-platform.ts b/src/platforms/mariadb1010-platform.ts new file mode 100644 index 0000000..a957194 --- /dev/null +++ b/src/platforms/mariadb1010-platform.ts @@ -0,0 +1,3 @@ +import { MariaDB1060Platform } from "./mariadb1060-platform"; + +export class MariaDB1010Platform extends MariaDB1060Platform {} diff --git a/src/platforms/mariadb1052-platform.ts b/src/platforms/mariadb1052-platform.ts new file mode 100644 index 0000000..49dc986 --- /dev/null +++ b/src/platforms/mariadb1052-platform.ts @@ -0,0 +1,3 @@ +import { MariaDBPlatform } from "./mariadb-platform"; + +export class MariaDB1052Platform extends MariaDBPlatform {} diff --git a/src/platforms/mariadb1060-platform.ts b/src/platforms/mariadb1060-platform.ts new file mode 100644 index 0000000..91b7143 --- /dev/null +++ b/src/platforms/mariadb1060-platform.ts @@ -0,0 +1,9 @@ +import { DefaultSelectSQLBuilder } from "../sql/builder/default-select-sql-builder"; +import type { SelectSQLBuilder } from "../sql/builder/select-sql-builder"; +import { MariaDB1052Platform } from "./mariadb1052-platform"; + +export class MariaDB1060Platform extends MariaDB1052Platform { + public override createSelectSQLBuilder(): SelectSQLBuilder { + return new DefaultSelectSQLBuilder(this, "FOR UPDATE", "SKIP LOCKED"); + } +} diff --git a/src/platforms/mariadb110700-platform.ts b/src/platforms/mariadb110700-platform.ts new file mode 100644 index 0000000..5d195e4 --- /dev/null +++ b/src/platforms/mariadb110700-platform.ts @@ -0,0 +1,9 @@ +import type { KeywordList } from "./keywords/keyword-list"; +import { MariaDB117Keywords } from "./keywords/mariadb117-keywords"; +import { MariaDB1010Platform } from "./mariadb1010-platform"; + +export class MariaDB110700Platform extends MariaDB1010Platform { + protected override createReservedKeywordsList(): KeywordList { + return new MariaDB117Keywords(); + } +} diff --git a/src/platforms/mysql80-platform.ts b/src/platforms/mysql80-platform.ts new file mode 100644 index 0000000..e063a57 --- /dev/null +++ b/src/platforms/mysql80-platform.ts @@ -0,0 +1,15 @@ +import { DefaultSelectSQLBuilder } from "../sql/builder/default-select-sql-builder"; +import type { SelectSQLBuilder } from "../sql/builder/select-sql-builder"; +import type { KeywordList } from "./keywords/keyword-list"; +import { MySQL80Keywords } from "./keywords/mysql80-keywords"; +import { MySQLPlatform } from "./mysql-platform"; + +export class MySQL80Platform extends MySQLPlatform { + protected override createReservedKeywordsList(): KeywordList { + return new MySQL80Keywords(); + } + + public override createSelectSQLBuilder(): SelectSQLBuilder { + return new DefaultSelectSQLBuilder(this, "FOR UPDATE", "SKIP LOCKED"); + } +} diff --git a/src/platforms/mysql84-platform.ts b/src/platforms/mysql84-platform.ts new file mode 100644 index 0000000..15c0c6f --- /dev/null +++ b/src/platforms/mysql84-platform.ts @@ -0,0 +1,9 @@ +import type { KeywordList } from "./keywords/keyword-list"; +import { MySQL84Keywords } from "./keywords/mysql84-keywords"; +import { MySQL80Platform } from "./mysql80-platform"; + +export class MySQL84Platform extends MySQL80Platform { + protected override createReservedKeywordsList(): KeywordList { + return new MySQL84Keywords(); + } +} diff --git a/src/platforms/postgre-sql-platform.ts b/src/platforms/postgre-sql-platform.ts new file mode 100644 index 0000000..a45e764 --- /dev/null +++ b/src/platforms/postgre-sql-platform.ts @@ -0,0 +1,86 @@ +import type { Connection } from "../connection"; +import { PostgreSQLSchemaManager } from "../schema/postgre-sql-schema-manager"; +import { TransactionIsolationLevel } from "../transaction-isolation-level"; +import { Types } from "../types/types"; +import { AbstractPlatform } from "./abstract-platform"; +import type { KeywordList } from "./keywords/keyword-list"; +import { PostgreSQLKeywords } from "./keywords/postgresql-keywords"; + +export class PostgreSQLPlatform extends AbstractPlatform { + protected initializeDatazenTypeMappings(): Record { + return { + bigint: Types.BIGINT, + bigserial: Types.BIGINT, + bool: Types.BOOLEAN, + boolean: Types.BOOLEAN, + bytea: Types.BINARY, + char: Types.STRING, + date: Types.DATE_MUTABLE, + "double precision": Types.FLOAT, + float4: Types.SMALLFLOAT, + float8: Types.FLOAT, + int: Types.INTEGER, + int2: Types.SMALLINT, + int4: Types.INTEGER, + int8: Types.BIGINT, + integer: Types.INTEGER, + json: Types.JSON, + jsonb: Types.JSON, + numeric: Types.DECIMAL, + real: Types.SMALLFLOAT, + serial: Types.INTEGER, + smallint: Types.SMALLINT, + text: Types.TEXT, + time: Types.TIME_MUTABLE, + timestamp: Types.DATETIME_MUTABLE, + timestamptz: Types.DATETIMETZ_MUTABLE, + timetz: Types.DATETIMETZ_MUTABLE, + uuid: Types.GUID, + varchar: Types.STRING, + }; + } + + public getLocateExpression( + string: string, + substring: string, + start: string | null = null, + ): string { + if (start === null) { + return `POSITION(${substring} IN ${string})`; + } + + return `(POSITION(${substring} IN SUBSTRING(${string} FROM ${start})) + ${start} - 1)`; + } + + public getDateDiffExpression(date1: string, date2: string): string { + return `DATE_PART('day', (${date1})::timestamp - (${date2})::timestamp)`; + } + + public getCurrentDatabaseExpression(): string { + return "CURRENT_DATABASE()"; + } + + public getSetTransactionIsolationSQL(level: TransactionIsolationLevel): string { + return `SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL ${this.getTransactionIsolationLevelSQL(level)}`; + } + + public supportsSchemas(): boolean { + return true; + } + + public supportsSequences(): boolean { + return true; + } + + public supportsIdentityColumns(): boolean { + return true; + } + + protected createReservedKeywordsList(): KeywordList { + return new PostgreSQLKeywords(); + } + + public createSchemaManager(connection: Connection): PostgreSQLSchemaManager { + return new PostgreSQLSchemaManager(connection, this); + } +} diff --git a/src/platforms/postgre-sql120-platform.ts b/src/platforms/postgre-sql120-platform.ts new file mode 100644 index 0000000..abe2264 --- /dev/null +++ b/src/platforms/postgre-sql120-platform.ts @@ -0,0 +1,3 @@ +import { PostgreSQLPlatform } from "./postgre-sql-platform"; + +export class PostgreSQL120Platform extends PostgreSQLPlatform {} diff --git a/src/platforms/sqlite-platform.ts b/src/platforms/sqlite-platform.ts new file mode 100644 index 0000000..1df18e4 --- /dev/null +++ b/src/platforms/sqlite-platform.ts @@ -0,0 +1,84 @@ +import type { Connection } from "../connection"; +import { SQLiteSchemaManager } from "../schema/sqlite-schema-manager"; +import { TransactionIsolationLevel } from "../transaction-isolation-level"; +import { Types } from "../types/types"; +import { AbstractPlatform } from "./abstract-platform"; +import { NotSupported } from "./exception/not-supported"; +import type { KeywordList } from "./keywords/keyword-list"; +import { SQLiteKeywords } from "./keywords/sqlite-keywords"; + +export class SQLitePlatform extends AbstractPlatform { + protected initializeDatazenTypeMappings(): Record { + return { + bigint: Types.BIGINT, + blob: Types.BLOB, + boolean: Types.BOOLEAN, + char: Types.STRING, + date: Types.DATE_MUTABLE, + datetime: Types.DATETIME_MUTABLE, + decimal: Types.DECIMAL, + double: Types.FLOAT, + float: Types.FLOAT, + int: Types.INTEGER, + integer: Types.INTEGER, + numeric: Types.DECIMAL, + real: Types.FLOAT, + text: Types.TEXT, + time: Types.TIME_MUTABLE, + timestamp: Types.DATETIME_MUTABLE, + varchar: Types.STRING, + }; + } + + public getLocateExpression( + string: string, + substring: string, + start: string | null = null, + ): string { + if (start === null) { + return `INSTR(${string}, ${substring})`; + } + + return `(INSTR(SUBSTR(${string}, ${start}), ${substring}) + ${start} - 1)`; + } + + public getSubstringExpression( + string: string, + start: string, + length: string | null = null, + ): string { + if (length === null) { + return `SUBSTR(${string}, ${start})`; + } + + return `SUBSTR(${string}, ${start}, ${length})`; + } + + public getDateDiffExpression(date1: string, date2: string): string { + return `CAST((JULIANDAY(${date1}) - JULIANDAY(${date2})) AS INTEGER)`; + } + + public getCurrentDateSQL(): string { + return "DATE('now')"; + } + + public getCurrentTimeSQL(): string { + return "TIME('now')"; + } + + public getSetTransactionIsolationSQL(_level: TransactionIsolationLevel): string { + throw NotSupported.new("setTransactionIsolation"); + } + + public supportsIdentityColumns(): boolean { + return true; + } + + protected createReservedKeywordsList(): KeywordList { + return new SQLiteKeywords(); + } + + public createSchemaManager(connection: Connection): SQLiteSchemaManager { + return new SQLiteSchemaManager(connection, this); + } +} diff --git a/src/portability/driver.ts b/src/portability/driver.ts index 5e39333..0a1abf6 100644 --- a/src/portability/driver.ts +++ b/src/portability/driver.ts @@ -6,12 +6,13 @@ import { } from "../driver"; import type { ExceptionConverter } from "../driver/api/exception-converter"; import type { AbstractPlatform } from "../platforms/abstract-platform"; +import type { ServerVersionProvider } from "../server-version-provider"; import { Connection } from "./connection"; import { Converter } from "./converter"; import { OptimizeFlags } from "./optimize-flags"; export class Driver implements DriverInterface { - public readonly getDatabasePlatform?: () => AbstractPlatform; + public readonly getDatabasePlatform: (versionProvider: ServerVersionProvider) => AbstractPlatform; private readonly optimizeFlags = new OptimizeFlags(); constructor( @@ -19,9 +20,8 @@ export class Driver implements DriverInterface { private readonly mode: number, private readonly caseMode: ColumnCase | null, ) { - if (this.driver.getDatabasePlatform !== undefined) { - this.getDatabasePlatform = (): AbstractPlatform => this.driver.getDatabasePlatform!(); - } + this.getDatabasePlatform = (versionProvider: ServerVersionProvider): AbstractPlatform => + this.driver.getDatabasePlatform(versionProvider); } public get name(): string { @@ -36,9 +36,10 @@ export class Driver implements DriverInterface { const connection = await this.driver.connect(params); let portability = this.mode; - if (this.driver.getDatabasePlatform !== undefined) { - portability = this.optimizeFlags.apply(this.driver.getDatabasePlatform(), portability); - } + portability = this.optimizeFlags.apply( + this.driver.getDatabasePlatform(connection), + portability, + ); const convertEmptyStringToNull = (portability & Connection.PORTABILITY_EMPTY_TO_NULL) !== 0; const rightTrimString = (portability & Connection.PORTABILITY_RTRIM) !== 0; diff --git a/src/server-version-provider.ts b/src/server-version-provider.ts index 3f1d918..08d79f8 100644 --- a/src/server-version-provider.ts +++ b/src/server-version-provider.ts @@ -1,3 +1,3 @@ export interface ServerVersionProvider { - getServerVersion(): Promise; + getServerVersion(): string | Promise; } diff --git a/src/static-server-version-provider.ts b/src/static-server-version-provider.ts new file mode 100644 index 0000000..7b91ca6 --- /dev/null +++ b/src/static-server-version-provider.ts @@ -0,0 +1 @@ +export { StaticServerVersionProvider } from "./connection/static-server-version-provider"; diff --git a/src/tools/dsn-parser.ts b/src/tools/dsn-parser.ts index e11277c..a42dfa2 100644 --- a/src/tools/dsn-parser.ts +++ b/src/tools/dsn-parser.ts @@ -15,6 +15,15 @@ interface ParsedDsnUrl { user?: string; } +const DEFAULT_SCHEME_ALIASES: Record = { + pdo_pgsql: "pg", + pdo_sqlite: "sqlite3", + pgsql: "pg", + postgres: "pg", + postgresql: "pg", + sqlite: "sqlite3", +}; + export class DsnParser { constructor(private readonly schemeMapping: DsnSchemeMapping = {}) {} @@ -152,7 +161,7 @@ export class DsnParser { private parseDatabaseUrlScheme(scheme: string): DsnSchemeMappingValue { const driver = scheme.replaceAll("-", "_"); - return this.schemeMapping[driver] ?? driver; + return this.schemeMapping[driver] ?? DEFAULT_SCHEME_ALIASES[driver] ?? driver; } private decodeRawUrlComponent(value: string): string { From 95af675f13fd9e738a48ea117b2279d5f545f592 Mon Sep 17 00:00:00 2001 From: bernard-ng Date: Mon, 23 Feb 2026 17:14:21 +0200 Subject: [PATCH 05/24] feat: review exception and formalize api --- AGENTS.md | 39 +- CHANGELOG.md | 25 + README.md | 5 +- docs/architecture.md | 20 +- docs/configuration.md | 81 ++- docs/data-retrieval-and-manipulation.md | 2 + docs/doctrine-parity-audit.json | 623 ++++++++++++++++++ docs/introduction.md | 7 +- docs/known-vendor-issues.md | 55 +- docs/parity-matrix.md | 17 +- docs/platforms.md | 27 +- docs/supporting-other-databases.md | 11 +- index.ts | 1 - .../connection-data-manipulation.test.ts | 71 +- ...database-platform-version-provider.test.ts | 38 +- .../connection-exception-conversion.test.ts | 62 +- .../connection-parameter-compilation.test.ts | 102 ++- .../connection/connection-transaction.test.ts | 191 +++--- .../connection-type-conversion.test.ts | 80 ++- .../connection/connection-typed-fetch.test.ts | 36 +- .../primary-read-replica-connection.test.ts | 287 ++++++++ .../static-server-version-provider.test.ts | 2 +- .../driver/abstract-db2-driver.test.ts | 29 + ...-oracle-driver-easy-connect-string.test.ts | 80 +++ ...-sqlite-driver-enable-foreign-keys.test.ts | 86 +++ .../driver/driver-exception-converter.test.ts | 20 +- .../driver/driver-exception-parity.test.ts | 31 + src/__tests__/driver/driver-manager.test.ts | 67 +- .../driver/ibmdb2-exception-converter.test.ts | 105 +++ .../abstract-connection-middleware.test.ts | 87 +++ .../abstract-driver-middleware.test.ts | 49 ++ .../abstract-result-middleware.test.ts | 46 ++ .../abstract-statement-middleware.test.ts | 33 + src/__tests__/driver/mssql-connection.test.ts | 73 +- src/__tests__/driver/mssql-driver.test.ts | 16 +- .../driver/mysql2-connection.test.ts | 99 +-- src/__tests__/driver/mysql2-driver.test.ts | 68 +- .../driver/oci-exception-converter.test.ts | 151 +++++ src/__tests__/driver/pg-connection.test.ts | 61 +- src/__tests__/driver/pg-driver.test.ts | 37 +- .../driver/sqlite3-connection.test.ts | 62 +- src/__tests__/driver/sqlite3-driver.test.ts | 18 +- .../version-aware-platform-driver.test.ts | 90 +++ .../doctrine-exception-shims.test.ts | 46 ++ .../exception/driver-exception.test.ts | 43 ++ .../exception/invalid-column-type.test.ts | 33 + .../top-level-exception-parity.test.ts | 71 ++ src/__tests__/logging/middleware.test.ts | 109 +-- src/__tests__/package/subpath-exports.test.ts | 50 +- .../array-parameters-exception.test.ts | 22 + .../parameter/expand-array-parameters.test.ts | 299 ++++++--- src/__tests__/portability/middleware.test.ts | 49 +- src/__tests__/query/query-builder.test.ts | 41 +- src/__tests__/result/result.test.ts | 82 +-- .../schema/schema-exception-parity.test.ts | 64 +- src/__tests__/schema/schema-manager.test.ts | 46 +- .../schema-name-introspection-parity.test.ts | 6 +- src/__tests__/statement/statement.test.ts | 16 +- src/__tests__/tools/dsn-parser.test.ts | 5 +- src/__tests__/types/types.test.ts | 4 +- src/array-parameters/exception.ts | 1 + .../exception/missing-named-parameter.ts | 11 + .../exception/missing-positional-parameter.ts | 11 + src/configuration.ts | 10 +- src/connection-exception.ts | 3 + src/connection.ts | 265 ++++++-- .../primary-read-replica-connection.ts | 246 +++++++ src/driver-manager.ts | 25 +- src/driver.ts | 57 +- src/driver/abstract-db2-driver.ts | 17 + src/driver/abstract-exception.ts | 27 + src/driver/abstract-mysql-driver.ts | 83 +++ .../easy-connect-string.ts | 169 +++++ src/driver/abstract-postgre-sql-driver.ts | 38 ++ src/driver/abstract-sql-server-driver.ts | 17 + .../exception/port-without-host.ts | 10 + src/driver/abstract-sqlite-driver.ts | 17 + .../middleware/enable-foreign-keys.ts | 19 + src/driver/api/ibmdb2/exception-converter.ts | 138 ++++ src/driver/api/mysql/exception-converter.ts | 19 +- src/driver/api/oci/exception-converter.ts | 234 +++++++ src/driver/api/pgsql/exception-converter.ts | 19 +- .../exception-converter.ts | 19 +- src/driver/api/sqlite/exception-converter.ts | 19 +- src/driver/array-result.ts | 144 ++++ src/driver/connection.ts | 15 + src/driver/exception-converter.ts | 4 - src/driver/exception.ts | 14 + .../identity-columns-not-supported.ts | 12 + src/driver/exception/no-identity-value.ts | 12 + src/driver/fetch-utils.ts | 35 + src/driver/index.ts | 50 -- .../internal-parameter-binding-style.ts | 4 + src/driver/internal-result-types.ts | 10 + src/driver/middleware.ts | 5 + .../abstract-connection-middleware.ts | 50 ++ .../middleware/abstract-driver-middleware.ts | 21 + .../middleware/abstract-result-middleware.ts | 56 ++ .../abstract-statement-middleware.ts | 15 + src/driver/mssql/connection.ts | 122 ++-- src/driver/mssql/driver.ts | 22 +- src/driver/mssql/exception-converter.ts | 2 +- src/driver/mssql/statement.ts | 31 + src/driver/mysql2/connection.ts | 122 ++-- src/driver/mysql2/driver.ts | 88 +-- src/driver/mysql2/statement.ts | 30 + src/driver/pg/connection.ts | 98 ++- src/driver/pg/driver.ts | 44 +- src/driver/pg/statement.ts | 30 + src/driver/result.ts | 78 +++ src/driver/sqlite3/connection.ts | 131 ++-- src/driver/sqlite3/driver.ts | 22 +- src/driver/sqlite3/statement.ts | 32 + src/driver/statement.ts | 30 + src/exception/_util.ts | 25 + src/exception/commit-failed-rollback-only.ts | 15 + src/exception/connection-lost.ts | 3 + .../constraint-violation-exception.ts | 4 +- src/exception/database-does-not-exist.ts | 3 + .../database-object-exists-exception.ts | 3 + .../database-object-not-found-exception.ts | 10 + src/exception/database-required.ts | 12 + src/exception/dbal-exception.ts | 7 - src/exception/deadlock-exception.ts | 4 +- src/exception/driver-exception.ts | 115 +++- src/exception/driver-required-exception.ts | 8 +- src/exception/driver-required.ts | 16 + src/exception/index.ts | 23 - src/exception/invalid-argument-exception.ts | 8 + src/exception/invalid-column-declaration.ts | 20 + src/exception/invalid-column-index.ts | 16 + src/exception/invalid-column-type.ts | 8 + .../column-length-required.ts | 14 + .../column-precision-required.ts | 7 + .../column-scale-required.ts | 7 + .../column-values-required.ts | 14 + src/exception/invalid-driver-class.ts | 9 + src/exception/invalid-field-name-exception.ts | 3 + src/exception/invalid-parameter-exception.ts | 9 +- src/exception/invalid-wrapper-class.ts | 9 + src/exception/lock-wait-timeout-exception.ts | 4 + src/exception/malformed-dsn-exception.ts | 5 +- .../missing-named-parameter-exception.ts | 5 +- .../missing-positional-parameter-exception.ts | 5 +- .../mixed-parameter-style-exception.ts | 7 - ...ed-transactions-not-supported-exception.ts | 8 +- .../no-active-transaction-exception.ts | 8 +- src/exception/no-active-transaction.ts | 12 + src/exception/no-key-value-exception.ts | 8 +- src/exception/no-key-value.ts | 14 + .../non-unique-field-name-exception.ts | 3 + src/exception/parse-error.ts | 17 + src/exception/read-only-exception.ts | 3 + src/exception/retryable-exception.ts | 1 + src/exception/rollback-only-exception.ts | 8 +- src/exception/savepoints-not-supported.ts | 12 + src/exception/schema-does-not-exist.ts | 3 + src/exception/server-exception.ts | 3 + src/exception/sql-syntax-exception.ts | 3 - src/exception/syntax-error-exception.ts | 3 + src/exception/table-exists-exception.ts | 3 + src/exception/table-not-found-exception.ts | 3 + src/exception/transaction-rolled-back.ts | 3 + src/exception/unknown-driver-exception.ts | 10 +- src/exception/unknown-driver.ts | 9 + src/expand-array-parameters.ts | 102 +-- src/index.ts | 30 - src/logging/connection.ts | 66 +- src/logging/console-logger.ts | 43 -- src/logging/driver.ts | 14 +- src/logging/index.ts | 5 - src/logging/logger.ts | 42 ++ src/logging/middleware.ts | 6 +- src/logging/statement.ts | 58 ++ src/platforms/db2/.gitkeep | 0 src/platforms/index.ts | 32 - .../my-sql/charset-metadata-provider/.gitkeep | 0 .../collation-metadata-provider/.gitkeep | 0 .../mysql/charset-metadata-provider/.gitkeep | 0 .../collation-metadata-provider/.gitkeep | 0 src/platforms/oracle/.gitkeep | 0 src/platforms/postgre-sql/.gitkeep | 0 src/platforms/postgresql/.gitkeep | 0 .../sq-lite-metadata-provider/.gitkeep | 0 src/platforms/sql-server/sql/builder/.gitkeep | 0 .../sqlite/sqlite-metadata-provider/.gitkeep | 0 src/platforms/sqlserver/sql/builder/.gitkeep | 0 src/portability/connection.ts | 48 +- src/portability/converter.ts | 25 +- src/portability/driver.ts | 14 +- src/portability/index.ts | 6 - src/portability/result.ts | 81 ++- src/portability/statement.ts | 25 + src/query.ts | 15 +- src/query/for-update.ts | 5 +- .../for-update/conflict-resolution-mode.ts | 4 + src/query/index.ts | 17 - src/query/query-builder.ts | 21 +- src/result.ts | 118 +--- src/schema/abstract-asset.ts | 2 +- src/schema/collections/index.ts | 6 - src/schema/column-editor.ts | 2 +- src/schema/column.ts | 5 +- src/schema/default-expression/index.ts | 3 - src/schema/exception/index.ts | 31 - src/schema/foreign-key-constraint-editor.ts | 2 +- src/schema/foreign-key-constraint/index.ts | 3 - src/schema/index-editor.ts | 2 +- src/schema/index/index.ts | 2 - src/schema/index/indexed-column.ts | 2 +- src/schema/introspection/index.ts | 2 - .../introspection/metadata-processor/index.ts | 5 - src/schema/metadata/index.ts | 10 - src/schema/module.ts | 51 -- src/schema/name/index.ts | 8 - src/schema/name/parser/exception/index.ts | 5 - src/schema/name/parser/index.ts | 5 - src/schema/primary-key-constraint.ts | 2 +- src/schema/schema.ts | 12 +- src/schema/sequence-editor.ts | 2 +- src/schema/table-editor.ts | 2 +- src/schema/table.ts | 16 +- src/schema/unique-constraint.ts | 2 +- src/schema/view-editor.ts | 2 +- src/server-version-provider.ts | 3 + src/sql/builder/default-select-sql-builder.ts | 2 +- src/sql/index.ts | 8 - src/sql/parser.ts | 2 +- src/sql/parser/exception.ts | 9 +- src/statement.ts | 8 +- src/static-server-version-provider.ts | 1 - src/tools/console/command/.gitkeep | 0 .../console/connection-provider/.gitkeep | 0 src/tools/dsn-parser.ts | 2 +- src/tools/index.ts | 2 - src/types.ts | 14 - src/types/exception/types-exception.ts | 9 +- .../{index.ts => register-built-in-types.ts} | 51 -- tsconfig.json | 2 +- 239 files changed, 6127 insertions(+), 2358 deletions(-) create mode 100644 docs/doctrine-parity-audit.json delete mode 100644 index.ts create mode 100644 src/__tests__/connection/primary-read-replica-connection.test.ts create mode 100644 src/__tests__/driver/abstract-db2-driver.test.ts create mode 100644 src/__tests__/driver/abstract-oracle-driver-easy-connect-string.test.ts create mode 100644 src/__tests__/driver/abstract-sqlite-driver-enable-foreign-keys.test.ts create mode 100644 src/__tests__/driver/driver-exception-parity.test.ts create mode 100644 src/__tests__/driver/ibmdb2-exception-converter.test.ts create mode 100644 src/__tests__/driver/middleware/abstract-connection-middleware.test.ts create mode 100644 src/__tests__/driver/middleware/abstract-driver-middleware.test.ts create mode 100644 src/__tests__/driver/middleware/abstract-result-middleware.test.ts create mode 100644 src/__tests__/driver/middleware/abstract-statement-middleware.test.ts create mode 100644 src/__tests__/driver/oci-exception-converter.test.ts create mode 100644 src/__tests__/driver/version-aware-platform-driver.test.ts create mode 100644 src/__tests__/exception/doctrine-exception-shims.test.ts create mode 100644 src/__tests__/exception/driver-exception.test.ts create mode 100644 src/__tests__/exception/invalid-column-type.test.ts create mode 100644 src/__tests__/exception/top-level-exception-parity.test.ts create mode 100644 src/__tests__/parameter/array-parameters-exception.test.ts create mode 100644 src/array-parameters/exception.ts create mode 100644 src/array-parameters/exception/missing-named-parameter.ts create mode 100644 src/array-parameters/exception/missing-positional-parameter.ts create mode 100644 src/connection-exception.ts create mode 100644 src/connections/primary-read-replica-connection.ts create mode 100644 src/driver/abstract-db2-driver.ts create mode 100644 src/driver/abstract-exception.ts create mode 100644 src/driver/abstract-mysql-driver.ts create mode 100644 src/driver/abstract-oracle-driver/easy-connect-string.ts create mode 100644 src/driver/abstract-postgre-sql-driver.ts create mode 100644 src/driver/abstract-sql-server-driver.ts create mode 100644 src/driver/abstract-sql-server-driver/exception/port-without-host.ts create mode 100644 src/driver/abstract-sqlite-driver.ts create mode 100644 src/driver/abstract-sqlite-driver/middleware/enable-foreign-keys.ts create mode 100644 src/driver/api/ibmdb2/exception-converter.ts create mode 100644 src/driver/api/oci/exception-converter.ts rename src/driver/api/{sqlsrv => sql-server}/exception-converter.ts (84%) create mode 100644 src/driver/array-result.ts create mode 100644 src/driver/connection.ts delete mode 100644 src/driver/exception-converter.ts create mode 100644 src/driver/exception.ts create mode 100644 src/driver/exception/identity-columns-not-supported.ts create mode 100644 src/driver/exception/no-identity-value.ts create mode 100644 src/driver/fetch-utils.ts delete mode 100644 src/driver/index.ts create mode 100644 src/driver/internal-parameter-binding-style.ts create mode 100644 src/driver/internal-result-types.ts create mode 100644 src/driver/middleware.ts create mode 100644 src/driver/middleware/abstract-connection-middleware.ts create mode 100644 src/driver/middleware/abstract-driver-middleware.ts create mode 100644 src/driver/middleware/abstract-result-middleware.ts create mode 100644 src/driver/middleware/abstract-statement-middleware.ts create mode 100644 src/driver/mssql/statement.ts create mode 100644 src/driver/mysql2/statement.ts create mode 100644 src/driver/pg/statement.ts create mode 100644 src/driver/result.ts create mode 100644 src/driver/sqlite3/statement.ts create mode 100644 src/driver/statement.ts create mode 100644 src/exception/_util.ts create mode 100644 src/exception/commit-failed-rollback-only.ts create mode 100644 src/exception/connection-lost.ts create mode 100644 src/exception/database-does-not-exist.ts create mode 100644 src/exception/database-object-exists-exception.ts create mode 100644 src/exception/database-object-not-found-exception.ts create mode 100644 src/exception/database-required.ts delete mode 100644 src/exception/dbal-exception.ts create mode 100644 src/exception/driver-required.ts delete mode 100644 src/exception/index.ts create mode 100644 src/exception/invalid-argument-exception.ts create mode 100644 src/exception/invalid-column-declaration.ts create mode 100644 src/exception/invalid-column-index.ts create mode 100644 src/exception/invalid-column-type.ts create mode 100644 src/exception/invalid-column-type/column-length-required.ts create mode 100644 src/exception/invalid-column-type/column-precision-required.ts create mode 100644 src/exception/invalid-column-type/column-scale-required.ts create mode 100644 src/exception/invalid-column-type/column-values-required.ts create mode 100644 src/exception/invalid-driver-class.ts create mode 100644 src/exception/invalid-field-name-exception.ts create mode 100644 src/exception/invalid-wrapper-class.ts create mode 100644 src/exception/lock-wait-timeout-exception.ts delete mode 100644 src/exception/mixed-parameter-style-exception.ts create mode 100644 src/exception/no-active-transaction.ts create mode 100644 src/exception/no-key-value.ts create mode 100644 src/exception/non-unique-field-name-exception.ts create mode 100644 src/exception/parse-error.ts create mode 100644 src/exception/read-only-exception.ts create mode 100644 src/exception/retryable-exception.ts create mode 100644 src/exception/savepoints-not-supported.ts create mode 100644 src/exception/schema-does-not-exist.ts create mode 100644 src/exception/server-exception.ts delete mode 100644 src/exception/sql-syntax-exception.ts create mode 100644 src/exception/syntax-error-exception.ts create mode 100644 src/exception/table-exists-exception.ts create mode 100644 src/exception/table-not-found-exception.ts create mode 100644 src/exception/transaction-rolled-back.ts create mode 100644 src/exception/unknown-driver.ts delete mode 100644 src/index.ts delete mode 100644 src/logging/console-logger.ts delete mode 100644 src/logging/index.ts create mode 100644 src/logging/statement.ts create mode 100644 src/platforms/db2/.gitkeep delete mode 100644 src/platforms/index.ts create mode 100644 src/platforms/my-sql/charset-metadata-provider/.gitkeep create mode 100644 src/platforms/my-sql/collation-metadata-provider/.gitkeep create mode 100644 src/platforms/mysql/charset-metadata-provider/.gitkeep create mode 100644 src/platforms/mysql/collation-metadata-provider/.gitkeep create mode 100644 src/platforms/oracle/.gitkeep create mode 100644 src/platforms/postgre-sql/.gitkeep create mode 100644 src/platforms/postgresql/.gitkeep create mode 100644 src/platforms/sq-lite/sq-lite-metadata-provider/.gitkeep create mode 100644 src/platforms/sql-server/sql/builder/.gitkeep create mode 100644 src/platforms/sqlite/sqlite-metadata-provider/.gitkeep create mode 100644 src/platforms/sqlserver/sql/builder/.gitkeep delete mode 100644 src/portability/index.ts create mode 100644 src/portability/statement.ts create mode 100644 src/query/for-update/conflict-resolution-mode.ts delete mode 100644 src/query/index.ts delete mode 100644 src/schema/collections/index.ts delete mode 100644 src/schema/default-expression/index.ts delete mode 100644 src/schema/exception/index.ts delete mode 100644 src/schema/foreign-key-constraint/index.ts delete mode 100644 src/schema/index/index.ts delete mode 100644 src/schema/introspection/index.ts delete mode 100644 src/schema/introspection/metadata-processor/index.ts delete mode 100644 src/schema/metadata/index.ts delete mode 100644 src/schema/module.ts delete mode 100644 src/schema/name/index.ts delete mode 100644 src/schema/name/parser/exception/index.ts delete mode 100644 src/schema/name/parser/index.ts delete mode 100644 src/sql/index.ts delete mode 100644 src/static-server-version-provider.ts create mode 100644 src/tools/console/command/.gitkeep create mode 100644 src/tools/console/connection-provider/.gitkeep delete mode 100644 src/tools/index.ts delete mode 100644 src/types.ts rename src/types/{index.ts => register-built-in-types.ts} (59%) diff --git a/AGENTS.md b/AGENTS.md index c76bb16..0800c68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,40 +10,17 @@ This project keeps namespace/folder parity with Doctrine DBAL where possible, wh - Prefer adding Doctrine-like aliases/shims instead of breaking existing imports. - Awlays add tests for new features and to cover any gaps in existing test coverage. - Validate against Doctrine's test suite where possible, and add any missing tests to this project as needed. +- PHP should be refered as "Node" in code and documentation +- Doctrine should be refered as "Datazen" in code and documentation - Run "bun run format" and "bun run test" before submitting any changes to ensure code quality and test coverage. +- When in doubt, follow Doctrine's implementation and naming conventions as closely as possible, while still adhering to TypeScript best practices and Node idioms. +- When a task is a refactoring, breaking changes are allowed, even full rewrites, refactorings, and reorgs. +- Try to keep 1:1 parity with Doctrine's classes and interfaces as much as possible, but don't be afraid to deviate a bit when it makes sense for TypeScript or Node. +- OCI8,PDO, MySQLI are native to PHP and have no direct equivalent in Node so ignore any references to those in Doctrine. +- mysql2, mssql, pgsql, sqlite3 are node drivers that have some similarities to PDO drivers but are not direct ports, so treat them as their own unique implementations and only borrow concepts and patterns from Doctrine where it makes sense. +- use "./" imports for internal modules to avoid relative import hell and make it easier to refactor and reorganize code without breaking imports. - Once validated add a summary of your changes in CHANGELOG.md -## Current Namespace Parity - -- `src/connection.ts` <-> `Doctrine\DBAL\Connection` -- `src/driver-manager.ts` <-> `Doctrine\DBAL\DriverManager` -- `src/result.ts` <-> `Doctrine\DBAL\Result` -- `src/statement.ts` <-> `Doctrine\DBAL\Statement` -- `src/query/*` <-> `Doctrine\DBAL\Query\*` -- `src/platforms/*` <-> `Doctrine\DBAL\Platforms\*` -- `src/platforms/exception/*` <-> `Doctrine\DBAL\Platforms\Exception\*` -- `src/sql/parser.ts` <-> `Doctrine\DBAL\SQL\Parser` -- `src/sql/parser/visitor.ts` <-> `Doctrine\DBAL\SQL\Parser\Visitor` -- `src/sql/parser/exception.ts` <-> `Doctrine\DBAL\SQL\Parser\Exception` -- `src/sql/parser/exception/regular-expression-error.ts` <-> `Doctrine\DBAL\SQL\Parser\Exception\RegularExpressionError` -- `src/driver/api/exception-converter.ts` <-> `Doctrine\DBAL\Driver\API\ExceptionConverter` -- `src/driver/api/mysql/exception-converter.ts` <-> `Doctrine\DBAL\Driver\API\MySQL\ExceptionConverter` -- `src/driver/api/sqlsrv/exception-converter.ts` <-> `Doctrine\DBAL\Driver\API\SQLSrv\ExceptionConverter` -- `src/types/*` <-> `Doctrine\DBAL\Types\*` -- `src/types/exception/*` <-> `Doctrine\DBAL\Types\Exception\*` - -## Test Namespace Parity (Best Effort) - -- `src/__tests__/connection/*` <-> `Doctrine\DBAL\Tests\Connection\*` -- `src/__tests__/driver/*` <-> `Doctrine\DBAL\Tests\Driver\*` -- `src/__tests__/parameter/*` <-> `Doctrine\DBAL\Tests\ArrayParameters\*` + execution parameter coverage -- `src/__tests__/platforms/*` <-> `Doctrine\DBAL\Tests\Platforms\*` -- `src/__tests__/query/*` <-> `Doctrine\DBAL\Tests\Query\*` -- `src/__tests__/result/*` <-> `Doctrine\DBAL\Tests\Result\*` -- `src/__tests__/sql/*` <-> `Doctrine\DBAL\Tests\SQL\Parser\*` -- `src/__tests__/statement/*` <-> `Doctrine\DBAL\Tests\Statement\*` -- `src/__tests__/types/*` <-> `Doctrine\DBAL\Tests\Types\*` - ## Intentional TS/Node Deviations - `src/driver/mysql2/*` and `src/driver/mssql/*` are Node-driver adapters, not PDO driver ports. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd0b9c..f647dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ # @devscast/datazen # Unreleased +- Exception: removed the port-specific `DbalException` base class and switched DBAL exception pass-through detection to an internal marker helper (`src/exception/_util.ts`), updated adapter runtime guard errors to plain `Error`, and aligned Doctrine exception hierarchy/tests (including `SyntaxErrorException`/`TransactionRolledBack` server-exception inheritance and `sql-syntax` test usage -> `SyntaxErrorException`). +- Exception: made `src/exception/driver-exception.ts` support Doctrine-style wrapping (`new DriverException(driverException, query?)`) with query-aware prefixed messages plus `getSQLState()`/`getQuery()`, while preserving the current converter-facing normalized-details constructor during the rewrite. +- Tests: cleaned up remaining failing `logging`, `portability`, `query`, `result`, `tools`, `schema`, `types`, and `package` suites to use direct imports (no removed barrels), Doctrine-shaped driver test doubles (`prepare/query/exec` + `ArrayResult`), and internal binding-style test helpers instead of public `ParameterBindingStyle`; full test suite is green again. +- Exception: implemented Doctrine-style `InvalidColumnType` base exception and nested factory exceptions (`ColumnLengthRequired`, `ColumnPrecisionRequired`, `ColumnScaleRequired`, `ColumnValuesRequired`) with targeted parity tests. +- Tests/connection: rewrote remaining connection-suite driver spies to the Doctrine-shaped driver API (`prepare/query/exec` + driver `Result`) and fixed direct imports (no deleted barrels), including explicit built-in type registration in isolated type-conversion tests. +- Exception: added Doctrine-named exception shims/aliases for parity (`ServerException`, `SyntaxErrorException`, `TransactionRolledBack`, `ConnectionLost`, `LockWaitTimeoutException`, `SavepointsNotSupported`, `DatabaseRequired`, `ReadOnlyException`, `NoActiveTransaction`, `CommitFailedRollbackOnly`, `RetryableException`) with targeted tests. +- Tests: aligned `PrimaryReadReplicaConnection` unit coverage more closely with Doctrine functional cases by adding explicit `keepReplica` transaction/insert stickiness checks and reconnect-to-replica behavior after `close()`. +- Reimplemented `src/connections/primary-read-replica-connection.ts` as a Doctrine-style DBAL connection wrapper (not a driver wrapper): primary/replica connection caches, sticky primary semantics, `keepReplica`, explicit `ensureConnectedToPrimary()` / `ensureConnectedToReplica()`, replica parameter charset inheritance, and primary-forcing on write/transaction/savepoint/prepare operations; also restored `Connection` extension hooks (`performConnect`, protected exception conversion/current-driver setters) and added DBAL savepoint methods for subclass parity. +- Aligned driver-module tests more closely with Doctrine DBAL test structure by splitting middleware abstract wrapper coverage into `src/__tests__/driver/middleware/*`, adding a Doctrine-style `src/__tests__/driver/version-aware-platform-driver.test.ts`, and removing non-Doctrine public `name`/`bindingStyle` assertions from concrete driver tests. +- Rewrote the runtime driver module to Doctrine-shaped APIs (`prepare/query/quote/exec/lastInsertId` + driver `Statement`/`Result`) for `sqlite3`, `mysql2`, `pg`, and `mssql`; removed driver-level `executeQuery`/`executeStatement` and savepoint methods from the public driver connection contract, moved nested savepoints to DBAL `Connection` platform SQL, and switched `src/result.ts`/driver middleware/portability wrappers to real driver results instead of migration DTOs. +- Removed port-specific `DriverQueryResult` / `DriverExecutionResult` from the public `src/driver.ts` API surface and moved them to internal `src/driver/internal-result-types.ts` to keep the exported driver API closer to Doctrine. +- Added missing Doctrine-style `AbstractDB2Driver` base class in `src/driver/abstract-db2-driver.ts` (Db2 platform + IBM DB2 exception converter wiring) with parity tests. +- Added Doctrine-style driver middleware wrapper bases in `src/driver/middleware/*` (`AbstractConnectionMiddleware`, `AbstractDriverMiddleware`, `AbstractResultMiddleware`, `AbstractStatementMiddleware`) with delegation tests and async-aware TS signatures for Node driver contracts. +- Completed Doctrine-style DB2/OCI exception-converter parity for field/table/database-not-found mappings by porting missing DBAL exception classes (`InvalidFieldNameException`, `NonUniqueFieldNameException`, `TableNotFoundException`, `TableExistsException`, `DatabaseDoesNotExist`, `DatabaseObjectNotFoundException`) and wiring Oracle/DB2 SQLCODE/ORA mappings to them with updated converter tests. +- Refactored Node driver adapters (`sqlite3`, `mysql2`, `pg`, `mssql`) to inherit new Doctrine-style abstract driver base classes (`AbstractSQLiteDriver`, `AbstractMySQLDriver`, `AbstractPostgreSQLDriver`, `AbstractSQLServerDriver`), centralizing platform selection and driver API exception-converter wiring while preserving async `connect()` behavior for Node clients. +- Ported Doctrine-style Oracle OCI API exception conversion in `src/driver/api/oci/exception-converter.ts`, including Oracle error-code mappings and best-effort nested `ORA-02091` rollback-cause conversion, with targeted unit tests. +- Ported Doctrine-style IBM DB2 API exception conversion in `src/driver/api/ibmdb2/exception-converter.ts`, including DB2 SQLCODE mappings for syntax, FK, unique, not-null, and connection errors, plus targeted converter tests. +- Ported Doctrine-style Oracle `EasyConnectString` rendering and connection-parameter assembly in `src/driver/abstract-oracle-driver/easy-connect-string.ts`, including `service` deprecation warning behavior and unit coverage for descriptor generation branches. +- Added a larger Doctrine-parity batch with canonical path moves (moved `PrimaryReadReplicaConnection` implementation to `src/connections/primary-read-replica-connection.ts` and removed the top-level compatibility shim), new Doctrine-shaped array-parameter exceptions, driver exception abstractions/factories, SQL Server/SQLite driver middleware parity files, and Doctrine-path API converter aliases (`driver/api/postgre-sql`, `driver/api/sql-server`) while allowing breaking import changes. +- Tightened Doctrine parity auditing with DataZen path normalization rules (`mysql`, `sqlite3`, `sqlite`, `sql-server`, `pgsql`), added a code naming scan for `Doctrine*`/`PHP*` tokens in `src`, corrected placeholder namespace folder names to match those conventions, and split `Query\\ForUpdate\\ConflictResolutionMode` into its own file for one-type-per-file parity. +- Added a repeatable Doctrine parity audit script (`scripts/doctrine-parity-audit.mjs`), generated a structural audit report (`docs/doctrine-parity-audit.json`), and scaffolded missing Doctrine-mapped `src/*` namespace folders with `.gitkeep` placeholders for incremental file-by-file porting. +- Updated the Doctrine parity audit to exclude intentional driver exceptions (PHP-specific Doctrine driver namespaces and Node-specific adapter naming differences such as `mysql2`, `pg`, `sqlite3`, `mssql`) and the Doctrine `cache/*` subsystem (PHP PSR-dependent) from missing-file counts. +- Refined Doctrine parity naming in `src/driver/primary-read-replica/*` by exporting the wrapper as `Connection` from `connection.ts` and using import aliases at call sites instead of a Doctrine-nonexistent `PrimaryReadReplicaDriverConnection` class name. +- Added Doctrine-inspired `PrimaryReadReplicaConnection` support with `DriverManager.getPrimaryReadReplicaConnection(...)`, replica-read/primary-write routing, primary pinning during transactions, and explicit switching helpers. +- Documentation: refreshed parity/status docs to reflect current runtime support (`mysql2`, `mssql`, `pg`, `sqlite3`), platform coverage (MySQL/MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, Db2), partial version-based platform selection, and partial Schema module availability. - Breaking: made `Driver.getDatabasePlatform(versionProvider)` driver-owned and required, removed `Connection` driver-name platform fallbacks, added Doctrine-style `StaticServerVersionProvider` selection from `serverVersion` / `primary.serverVersion`, introduced semver-based MySQL/MariaDB versioned platform resolution (`MySQL80/84`, `MariaDB1052/1060/1010/110700`), and now throw `InvalidPlatformVersion` for malformed platform version strings. - Added `pg` and `sqlite3` driver adapters (connections, exception converters, driver-manager registration, and driver barrel exports) with best-effort Doctrine-style PostgreSQL/SQLite platform classes and DSN scheme aliases (`postgres*` -> `pg`, `sqlite` -> `sqlite3`). - Added package subpath namespace exports so consumers can import grouped APIs from: diff --git a/README.md b/README.md index fdd0ccf..1731f0d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,10 @@ For SQL Server projects: bun add @devscast/datazen mssql ``` -`mysql2` and `mssql` are peer dependencies so applications control driver versions. +Other supported runtime drivers include `pg` and `sqlite3`. + +`mysql2`, `mssql`, `pg`, and `sqlite3` are peer dependencies so applications +control driver versions. ## Documentation diff --git a/docs/architecture.md b/docs/architecture.md index c913771..166aa7f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,8 +4,9 @@ Architecture DataZen keeps the same global architecture idea as Doctrine DBAL: a driver layer at the bottom and a stable wrapper layer at the top. -This port is TypeScript/Node-first, async, and intentionally excludes the -Schema module for now. +This port is TypeScript/Node-first, async, and intentionally does not yet cover +the full Doctrine DBAL feature matrix. A partial Schema module is included and +continues to evolve toward parity. Layers ------ @@ -35,7 +36,7 @@ The driver abstraction is centered around: Concrete adapters: -- MySQL2 and MSSQL adapters are exposed through `@devscast/datazen/driver` +- MySQL2, MSSQL, pg, and sqlite3 adapters are exposed through `@devscast/datazen/driver` Doctrine has separate low-level `Driver\Statement` and `Driver\Result` interfaces. In this Node port, the low-level contract is simplified to @@ -65,7 +66,7 @@ The middleware pipeline is configured via `Configuration` (root import: `@devsca Parameter Expansion and SQL Parsing ----------------------------------- -Array/list parameter expansion follows Doctrine’s model: +Array/list parameter expansion follows Doctrine's model: - `ExpandArrayParameters` (root import: `@devscast/datazen`) - SQL parser + visitor (`@devscast/datazen/sql`) @@ -79,7 +80,7 @@ Platforms Platforms provide dialect capabilities and feature flags through `AbstractPlatform` (from `@devscast/datazen/platforms`) and concrete -implementations (`mysql`, `sql-server`, `oracle`, `db2`). +implementations (MySQL/MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, Db2). They are used for SQL dialect behaviors, quoting, date/time and expression helpers, and type mapping metadata. @@ -99,13 +100,20 @@ The query API (`@devscast/datazen/query`) includes a Doctrine-inspired QueryBuil related expression/query objects. Query generation and execution remain separated: generated SQL is executed through `Connection`. +Schema Layer (Partial) +---------------------- + +The schema API (`@devscast/datazen/schema`) is available as a separate module +and includes schema assets, comparators/diffs, editors, schema managers, and +metadata/introspection helpers. Doctrine-level schema parity remains partial. + Exceptions ---------- Exceptions are normalized in `@devscast/datazen/exception`. Driver-specific errors are translated through per-driver exception converters: -- `MySQLExceptionConverter` / `SQLSrvExceptionConverter` from `@devscast/datazen/driver` +- `MySQLExceptionConverter`, `SQLSrvExceptionConverter`, `PgSQLExceptionConverter`, and `SQLiteExceptionConverter` from `@devscast/datazen/driver` Tools ----- diff --git a/docs/configuration.md b/docs/configuration.md index 0fe052f..3ddb8ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -54,7 +54,8 @@ const conn = DriverManager.getConnection({ ``` Important: built-in drivers need an already-created low-level client (`pool`, -`connection`, or `client`). DSN parsing does not create a driver client. +`connection`, or `client`; `sqlite3` may also use `database`). DSN parsing does +not create a driver client. Driver ------ @@ -69,6 +70,8 @@ Built-in drivers currently available: - `mysql2` - `mssql` +- `pg` +- `sqlite3` There is no `wrapperClass` option in this port. @@ -77,7 +80,7 @@ Connection Parameters Core manager-level params: -- `driver?: "mysql2" | "mssql"` +- `driver?: "mysql2" | "mssql" | "pg" | "sqlite3"` - `driverClass?: new () => Driver` - `driverInstance?: Driver` - `platform?: AbstractPlatform` (optional platform override) @@ -110,6 +113,33 @@ Optional ownership flags: - `ownsPool?: boolean` - `ownsClient?: boolean` +PostgreSQL (pg) Driver Params +----------------------------- + +At least one is required: + +- `pool` +- `connection` +- `client` + +Optional ownership flags: + +- `ownsPool?: boolean` +- `ownsClient?: boolean` + +SQLite3 Driver Params +--------------------- + +At least one is required: + +- `database` +- `connection` +- `client` + +Optional ownership flags: + +- `ownsClient?: boolean` + Notes: - Keys like `host`, `port`, `user`, `password`, `dbname` can be present (for @@ -199,11 +229,50 @@ Platform Selection Platform resolution order: 1. `params.platform` when provided -2. Driver-provided platform (`mysql2` -> `MySQLPlatform`, `mssql` -> `SQLServerPlatform`) -3. Driver-name fallback in `Connection` +2. Driver-provided platform (`mysql2` -> MySQL/MariaDB family, `mssql` -> `SQLServerPlatform`, `pg` -> PostgreSQL family, `sqlite3` -> `SQLitePlatform`) + +Version-based platform selection is partially implemented: + +- `mysql2` resolves MySQL/MariaDB platform variants from configured `serverVersion` / `primary.serverVersion` +- `pg` resolves PostgreSQL major-version variants from configured `serverVersion` / `primary.serverVersion` +- `mssql` and `sqlite3` currently return fixed platform classes + +Unlike Doctrine, full automatic version detection from a live async connection is +not available through the current synchronous `Driver#getDatabasePlatform()` +contract, so unconfigured connections fall back to base platform classes. + +Primary / Read-Replica Connection +--------------------------------- + +Datazen now provides a Doctrine-inspired `PrimaryReadReplicaConnection` wrapper +for routing reads to replicas and writes/transactions to the primary. + +Create it through `DriverManager`: + +```ts +import { DriverManager } from "@devscast/datazen"; + +const conn = DriverManager.getPrimaryReadReplicaConnection({ + driver: "mysql2", + primary: { pool: primaryPool }, + replica: { pool: replicaPool }, +}); +``` + +Supported params: + +- `primary` (required): connection params object for the primary node +- `replica` (optional): one replica params object +- `replicas` (optional): array of replica params objects + +Behavior notes: -Unlike Doctrine, version-specific automatic platform detection is not implemented -in this port. +- `executeQuery()` reads use a replica by default +- writes (`executeStatement()`) and transactions are routed to primary +- after writing or starting a transaction, reads stick to primary until you + explicitly call `ensureConnectedToReplica()` +- helper methods: `ensureConnectedToPrimary()`, `ensureConnectedToReplica()`, + `isConnectedToPrimary()`, `isConnectedToReplica()` Not Implemented --------------- diff --git a/docs/data-retrieval-and-manipulation.md b/docs/data-retrieval-and-manipulation.md index 00395ee..3e7a14a 100644 --- a/docs/data-retrieval-and-manipulation.md +++ b/docs/data-retrieval-and-manipulation.md @@ -158,6 +158,8 @@ Driver Binding Style Notes -------------------------- - MySQL2 driver executes positional bindings. +- PostgreSQL (`pg`) driver executes positional bindings. +- SQLite (`sqlite3`) driver executes positional bindings. - MSSQL driver executes named bindings. DataZen normalizes this at `Connection` level so application code can still use diff --git a/docs/doctrine-parity-audit.json b/docs/doctrine-parity-audit.json new file mode 100644 index 0000000..6c9cba7 --- /dev/null +++ b/docs/doctrine-parity-audit.json @@ -0,0 +1,623 @@ +{ + "generatedAt": "2026-02-23T09:58:18.305Z", + "scope": { + "referenceRoot": "references/dbal/src", + "portRoot": "src", + "note": "Structural parity only (folder/file path presence). Semantic port correctness is not verified by this script." + }, + "exceptions": { + "doctrineDriverNamespaceExceptions": [ + { + "prefix": "cache", + "reason": "Doctrine cache subsystem depends on PHP-specific PSR interfaces (for example PSR logging/cache) and is intentionally excluded from Node parity requirements." + }, + { + "prefix": "driver/mysqli", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "prefix": "driver/pdo", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "prefix": "driver/oci8", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "prefix": "driver/ibmdb2", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "prefix": "driver/api/oci", + "reason": "PHP-specific Doctrine driver API converter namespace (OCI)." + }, + { + "prefix": "driver/api/ibmdb2", + "reason": "PHP-specific Doctrine driver API converter namespace (ibmdb2)." + }, + { + "prefix": "driver/pgsql", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "prefix": "driver/sql-server", + "reason": "Node-specific adapter uses driver/mssql instead of Doctrine driver/sql-server." + } + ], + "note": "These Doctrine paths are excluded from missing counts due to Node-specific driver architecture or intentional PHP-only omissions." + }, + "conventions": { + "pathNormalization": [ + "MySQL -> mysql", + "SQLite3 -> sqlite3", + "SQLite -> sqlite", + "SQLSrv / SQL-srv -> sql-server", + "PgSQL -> pgsql" + ], + "codeRenames": [ + "Doctrine* -> Datazen*", + "toPHP* -> toNode*", + "PHP* -> Node*" + ], + "note": "Code rename checks are token-based and may include comments/strings for manual review." + }, + "counts": { + "doctrineDirs": 86, + "doctrineFiles": 432, + "portDirs": 119, + "portFiles": 383, + "rawMissingDirs": 8, + "rawMissingFiles": 164, + "ignoredMissingDirs": 8, + "ignoredMissingFiles": 73, + "missingDirs": 0, + "missingFiles": 91, + "codeNamingViolations": 0, + "placeholderOnlyDirs": 35, + "placeholderOnlyDoctrineMappedDirs": 21, + "actionablePlaceholderOnlyDirs": 11 + }, + "extraTopLevelPortDirs": [ + "__tests__" + ], + "createdDirs": [], + "gitkeepFiles": [], + "ignoredMissingDirs": [ + { + "path": "driver/pdo/mysql", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/pgsql", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/sql-server", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/sqlite", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pgsql", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/pgsql/exception", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/sql-server", + "reason": "Node-specific adapter uses driver/mssql instead of Doctrine driver/sql-server." + }, + { + "path": "driver/sql-server/exception", + "reason": "Node-specific adapter uses driver/mssql instead of Doctrine driver/sql-server." + } + ], + "ignoredMissingFiles": [ + { + "path": "cache/array-result", + "reason": "Doctrine cache subsystem depends on PHP-specific PSR interfaces (for example PSR logging/cache) and is intentionally excluded from Node parity requirements." + }, + { + "path": "cache/cache-exception", + "reason": "Doctrine cache subsystem depends on PHP-specific PSR interfaces (for example PSR logging/cache) and is intentionally excluded from Node parity requirements." + }, + { + "path": "cache/exception/no-cache-key", + "reason": "Doctrine cache subsystem depends on PHP-specific PSR interfaces (for example PSR logging/cache) and is intentionally excluded from Node parity requirements." + }, + { + "path": "cache/exception/no-result-driver-configured", + "reason": "Doctrine cache subsystem depends on PHP-specific PSR interfaces (for example PSR logging/cache) and is intentionally excluded from Node parity requirements." + }, + { + "path": "cache/query-cache-profile", + "reason": "Doctrine cache subsystem depends on PHP-specific PSR interfaces (for example PSR logging/cache) and is intentionally excluded from Node parity requirements." + }, + { + "path": "driver/api/ibmdb2/exception-converter", + "reason": "PHP-specific Doctrine driver API converter namespace (ibmdb2)." + }, + { + "path": "driver/api/oci/exception-converter", + "reason": "PHP-specific Doctrine driver API converter namespace (OCI)." + }, + { + "path": "driver/ibmdb2/connection", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/data-source-name", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/driver", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/exception/cannot-copy-stream-to-stream", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/exception/cannot-create-temporary-file", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/exception/connection-error", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/exception/connection-failed", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/exception/factory", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/exception/prepare-failed", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/exception/statement-error", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/result", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/ibmdb2/statement", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/mysqli/connection", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/driver", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/exception/connection-error", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/exception/connection-failed", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/exception/failed-reading-stream-offset", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/exception/host-required", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/exception/invalid-charset", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/exception/invalid-option", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/exception/non-stream-resource-used-as-large-object", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/exception/statement-error", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/initializer", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/initializer/charset", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/initializer/options", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/initializer/secure", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/result", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/statement", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/oci8/connection", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/convert-positional-to-named-placeholders", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/driver", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/exception/connection-failed", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/exception/error", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/exception/invalid-configuration", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/exception/non-terminated-string-literal", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/exception/unknown-parameter-index", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/execution-mode", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/middleware/initialize-session", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/result", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/statement", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/pdo/connection", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/exception", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/exception/invalid-configuration", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/mysql/driver", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/oci/driver", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/pdo-connect", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/pgsql/driver", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/result", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/sql-server/connection", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/sql-server/driver", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/sql-server/statement", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/sqlite/driver", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/statement", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pgsql/connection", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/pgsql/convert-parameters", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/pgsql/driver", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/pgsql/exception", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/pgsql/exception/unexpected-value", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/pgsql/exception/unknown-parameter", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/pgsql/result", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/pgsql/statement", + "reason": "Node-specific adapter uses driver/pg instead of Doctrine driver/pgsql." + }, + { + "path": "driver/sql-server/connection", + "reason": "Node-specific adapter uses driver/mssql instead of Doctrine driver/sql-server." + }, + { + "path": "driver/sql-server/driver", + "reason": "Node-specific adapter uses driver/mssql instead of Doctrine driver/sql-server." + }, + { + "path": "driver/sql-server/exception/error", + "reason": "Node-specific adapter uses driver/mssql instead of Doctrine driver/sql-server." + }, + { + "path": "driver/sql-server/result", + "reason": "Node-specific adapter uses driver/mssql instead of Doctrine driver/sql-server." + }, + { + "path": "driver/sql-server/statement", + "reason": "Node-specific adapter uses driver/mssql instead of Doctrine driver/sql-server." + } + ], + "missingDirs": [], + "missingFiles": [ + "connection-exception", + "driver/abstract-db2-driver", + "driver/abstract-mysql-driver", + "driver/abstract-oracle-driver", + "driver/abstract-oracle-driver/easy-connect-string", + "driver/abstract-postgre-sql-driver", + "driver/abstract-sql-server-driver", + "driver/abstract-sqlite-driver", + "driver/connection", + "driver/fetch-utils", + "driver/middleware", + "driver/middleware/abstract-connection-middleware", + "driver/middleware/abstract-result-middleware", + "driver/middleware/abstract-statement-middleware", + "driver/result", + "driver/sqlite3/exception", + "driver/sqlite3/result", + "driver/sqlite3/statement", + "driver/statement", + "exception/commit-failed-rollback-only", + "exception/connection-lost", + "exception/database-does-not-exist", + "exception/database-object-exists-exception", + "exception/database-object-not-found-exception", + "exception/database-required", + "exception/driver-required", + "exception/invalid-argument-exception", + "exception/invalid-column-declaration", + "exception/invalid-column-index", + "exception/invalid-column-type", + "exception/invalid-column-type/column-length-required", + "exception/invalid-column-type/column-precision-required", + "exception/invalid-column-type/column-scale-required", + "exception/invalid-column-type/column-values-required", + "exception/invalid-driver-class", + "exception/invalid-field-name-exception", + "exception/invalid-wrapper-class", + "exception/lock-wait-timeout-exception", + "exception/no-active-transaction", + "exception/no-key-value", + "exception/non-unique-field-name-exception", + "exception/parse-error", + "exception/read-only-exception", + "exception/retryable-exception", + "exception/savepoints-not-supported", + "exception/schema-does-not-exist", + "exception/server-exception", + "exception/syntax-error-exception", + "exception/table-exists-exception", + "exception/table-not-found-exception", + "exception/transaction-rolled-back", + "exception/unknown-driver", + "platforms/db2/db2-metadata-provider", + "platforms/exception/no-columns-specified-for-table", + "platforms/keywords/maria-db-keywords", + "platforms/keywords/maria-db117-keywords", + "platforms/keywords/postgre-sql-keywords", + "platforms/maria-db-platform", + "platforms/maria-db1010-platform", + "platforms/maria-db1052-platform", + "platforms/maria-db1060-platform", + "platforms/maria-db110700-platform", + "platforms/mysql/charset-metadata-provider", + "platforms/mysql/charset-metadata-provider/caching-charset-metadata-provider", + "platforms/mysql/charset-metadata-provider/connection-charset-metadata-provider", + "platforms/mysql/collation-metadata-provider", + "platforms/mysql/collation-metadata-provider/caching-collation-metadata-provider", + "platforms/mysql/collation-metadata-provider/connection-collation-metadata-provider", + "platforms/mysql/comparator", + "platforms/mysql/default-table-options", + "platforms/mysql/mysql-metadata-provider", + "platforms/oracle/oracle-metadata-provider", + "platforms/postgre-sql/postgre-sql-metadata-provider", + "platforms/sql-server/comparator", + "platforms/sql-server/sql-server-metadata-provider", + "platforms/sql-server/sql/builder/sql-server-select-sql-builder", + "platforms/sqlite/comparator", + "platforms/sqlite/sqlite-metadata-provider", + "platforms/sqlite/sqlite-metadata-provider/foreign-key-constraint-details", + "sql/builder/create-schema-objects-sql-builder", + "sql/builder/drop-schema-objects-sql-builder", + "sql/parser/exception/regular-expression-error", + "tools/console/command/run-sql-command", + "tools/console/connection-not-found", + "tools/console/connection-provider", + "tools/console/connection-provider/single-connection-provider", + "types/exception/type-argument-count-error", + "types/php-date-mapping-type", + "types/php-date-time-mapping-type", + "types/php-integer-mapping-type", + "types/php-time-mapping-type" + ], + "codeNamingViolations": [], + "placeholderOnlyDirs": [ + "cache/exception", + "driver/abstract-oracle-driver", + "driver/api/ibmdb2", + "driver/api/my-sql", + "driver/api/oci", + "driver/api/sq-lite", + "driver/ibmdb2/exception", + "driver/mysqli/exception", + "driver/mysqli/initializer", + "driver/oci8/exception", + "driver/oci8/middleware", + "driver/pdo/exception", + "driver/pdo/my-sql", + "driver/pdo/oci", + "driver/pdo/pg-sql", + "driver/pdo/sq-lite", + "driver/pdo/sql-srv", + "driver/pg-sql/exception", + "driver/sq-lite3", + "driver/sql-srv/exception", + "exception/invalid-column-type", + "platforms/db2", + "platforms/my-sql/charset-metadata-provider", + "platforms/my-sql/collation-metadata-provider", + "platforms/mysql/charset-metadata-provider", + "platforms/mysql/collation-metadata-provider", + "platforms/oracle", + "platforms/postgre-sql", + "platforms/postgresql", + "platforms/sq-lite/sq-lite-metadata-provider", + "platforms/sql-server/sql/builder", + "platforms/sqlite/sqlite-metadata-provider", + "platforms/sqlserver/sql/builder", + "tools/console/command", + "tools/console/connection-provider" + ], + "placeholderOnlyDoctrineMappedDirs": [ + "cache/exception", + "driver/abstract-oracle-driver", + "driver/api/ibmdb2", + "driver/api/oci", + "driver/ibmdb2/exception", + "driver/mysqli/exception", + "driver/mysqli/initializer", + "driver/oci8/exception", + "driver/oci8/middleware", + "driver/pdo/exception", + "driver/pdo/oci", + "exception/invalid-column-type", + "platforms/db2", + "platforms/mysql/charset-metadata-provider", + "platforms/mysql/collation-metadata-provider", + "platforms/oracle", + "platforms/postgre-sql", + "platforms/sql-server/sql/builder", + "platforms/sqlite/sqlite-metadata-provider", + "tools/console/command", + "tools/console/connection-provider" + ], + "ignoredPlaceholderOnlyDirs": [ + { + "path": "cache/exception", + "reason": "Doctrine cache subsystem depends on PHP-specific PSR interfaces (for example PSR logging/cache) and is intentionally excluded from Node parity requirements." + }, + { + "path": "driver/api/ibmdb2", + "reason": "PHP-specific Doctrine driver API converter namespace (ibmdb2)." + }, + { + "path": "driver/api/oci", + "reason": "PHP-specific Doctrine driver API converter namespace (OCI)." + }, + { + "path": "driver/ibmdb2/exception", + "reason": "PHP-specific Doctrine driver; no Node ibmdb2 adapter in this port." + }, + { + "path": "driver/mysqli/exception", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/mysqli/initializer", + "reason": "PHP-specific Doctrine driver; Node port uses mysql2 adapter instead." + }, + { + "path": "driver/oci8/exception", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/oci8/middleware", + "reason": "PHP-specific Doctrine driver; no Node OCI adapter in this port." + }, + { + "path": "driver/pdo/exception", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + }, + { + "path": "driver/pdo/oci", + "reason": "PHP PDO-specific Doctrine drivers are intentionally not ported in Node." + } + ], + "actionablePlaceholderOnlyDirs": [ + "driver/abstract-oracle-driver", + "exception/invalid-column-type", + "platforms/db2", + "platforms/mysql/charset-metadata-provider", + "platforms/mysql/collation-metadata-provider", + "platforms/oracle", + "platforms/postgre-sql", + "platforms/sql-server/sql/builder", + "platforms/sqlite/sqlite-metadata-provider", + "tools/console/command", + "tools/console/connection-provider" + ] +} diff --git a/docs/introduction.md b/docs/introduction.md index 2ac3a0c..8ad68df 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -15,11 +15,15 @@ Runtime drivers currently shipped: - MySQL (via `mysql2`) - Microsoft SQL Server (via `mssql`) +- PostgreSQL (via `pg`) +- SQLite (via `sqlite3`) Platform abstractions currently shipped: -- MySQL +- MySQL / MariaDB (including versioned variants) - SQL Server +- PostgreSQL +- SQLite - Oracle (platform-only) - Db2 (platform-only) @@ -36,6 +40,7 @@ Current scope includes: - `Connection`, `Statement`, `Result` abstractions - QueryBuilder and SQL parser support - Type conversion subsystem +- Schema module foundations (`@devscast/datazen/schema`) with ongoing parity work - Driver middleware (logging, portability) - DSN parsing diff --git a/docs/known-vendor-issues.md b/docs/known-vendor-issues.md index f59c7d1..e012fff 100644 --- a/docs/known-vendor-issues.md +++ b/docs/known-vendor-issues.md @@ -11,12 +11,17 @@ Runtime drivers currently implemented: - MySQL (`mysql2`) - Microsoft SQL Server (`mssql`) +- PostgreSQL (`pg`) +- SQLite (`sqlite3`) + +MariaDB runtime support uses the `mysql2` adapter and selects MariaDB platform +variants when `serverVersion` is configured. Platform classes also exist for Oracle and Db2, but runtime drivers for those vendors are not implemented yet in this port. -MySQL (mysql2) --------------- +MySQL / MariaDB (mysql2) +------------------------ DateTimeTz behavior ------------------- @@ -121,12 +126,48 @@ Runtime support note No Db2 runtime driver adapter is currently shipped in this port, so behavior here applies to platform SQL/type logic only. -PostgreSQL, MariaDB, SQLite ---------------------------- +MariaDB variant selection +------------------------- + +When `serverVersion` (or `primary.serverVersion`) is provided and identifies a +MariaDB server, the `mysql2` driver selects a MariaDB platform variant class. + +Practical impact: + +- SQL/type behavior can differ from MySQL-specific defaults when version info is configured; +- without configured version info, Datazen may fall back to a base MySQL platform class. + +PostgreSQL (pg) +--------------- + +Runtime support note +-------------------- + +The `pg` adapter and PostgreSQL platform classes are shipped in this port, +including best-effort PostgreSQL major-version platform selection when +`serverVersion` is configured. + +Coverage note +------------- + +This page does not yet catalog PostgreSQL-specific runtime caveats as +comprehensively as MySQL/MSSQL. Validate vendor-specific behavior in integration +tests against your target PostgreSQL version. + +SQLite (sqlite3) +---------------- + +Runtime support note +-------------------- + +The `sqlite3` adapter and `SQLitePlatform` are shipped in this port. + +Coverage note +------------- -The current Datazen port does not ship runtime drivers/platform classes for -these vendors yet, so Doctrine-specific issues for these databases are not -applicable to current runtime support. +This page does not yet catalog SQLite-specific runtime caveats comprehensively. +Validate schema/DDL and type behavior in integration tests against your target +SQLite build/version. Workarounds and Recommendations ------------------------------- diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 7b119fd..7b3d017 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -17,14 +17,14 @@ Top-Level Parity | Area | Status | Notes | | --- | --- | --- | -| Connection / Statement / Result | Partial | Core runtime APIs are implemented; some Doctrine transaction APIs and behaviors remain missing. | -| DriverManager | Partial | Built-in resolution exists, but driver matrix is much smaller than Doctrine. | +| Connection / Statement / Result | Partial | Core runtime APIs are implemented, including Doctrine-inspired `PrimaryReadReplicaConnection`; some Doctrine transaction APIs and behaviors remain missing. | +| DriverManager | Partial | Built-in resolution exists for `mysql2`, `mssql`, `pg`, and `sqlite3`, but Doctrine's driver/vendor matrix is still much broader. | | Driver abstraction | Partial | TS/Node async contract differs intentionally from Doctrine's low-level driver interfaces. | | Query Builder | Partial | Core builder and execution APIs implemented; Doctrine result cache integration is missing. | | SQL Parser / SQL Builders | Partial | Parameter parser and SQL builder support exist; parity breadth is still evolving. | -| Platforms | Partial | MySQL, SQL Server, Oracle, Db2 platforms exist; version-specific platform variants/detection are incomplete. | +| Platforms | Partial | MySQL/MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, and Db2 platform classes exist. Versioned MySQL/MariaDB/PostgreSQL platform selection is implemented when a synchronous `serverVersion` is available, but full Doctrine matrix/detection parity is incomplete. | | Types | Partial | Strong runtime type system and registry support; parity breadth and schema-driven flows continue to evolve. | -| Schema | Partial | Significant schema module support exists (assets, managers, comparator/editors, metadata/introspection scaffolding), but full Doctrine parity is still in progress. | +| Schema | Partial | Significant schema module support exists (assets, managers, comparator/editors, metadata/introspection helpers, vendor schema managers), but full Doctrine parity is still in progress. | | Logging middleware | Implemented | Doctrine-inspired middleware pattern ported for Node driver wrapping. | | Portability middleware | Implemented | Result portability normalization and optimization flags are available. | | Tools (DSN parser) | Implemented | `DsnParser` exists and is documented/test-covered. | @@ -34,17 +34,18 @@ Doctrine Areas With Strong Coverage (Current) --------------------------------------------- - Driver middleware (`logging`, `portability`) +- Built-in runtime adapters for `mysql2`, `mssql`, `pg`, and `sqlite3` - SQL parameter parsing and array/list expansion flow - QueryBuilder core operations and execution helpers -- Platform SQL helpers and vendor keyword list access +- Platform SQL helpers, vendor keyword lists, and versioned MySQL/MariaDB/PostgreSQL platform variants - Types registry and built-in type conversions - Schema foundations (assets, diffs, editors, schema managers, metadata/introspection helpers) Known Major Gaps vs Doctrine DBAL --------------------------------- -- Wider driver support (Doctrine supports many more drivers/vendors than the current `mysql2` + `mssql` runtime adapters) -- Version-specific platform subclass selection and automatic server-version detection +- Wider driver support (Doctrine supports more drivers/vendors; Datazen currently ships runtime adapters for `mysql2`, `mssql`, `pg`, and `sqlite3`) +- Fully automatic version-based platform detection from live async connections (versioned selection is best-effort and primarily driven by configured `serverVersion` / `primary.serverVersion`) - QueryBuilder result cache integration (`enableResultCache()`-style API) - Connection transaction isolation getter/setter parity - Retryable exception marker semantics / lock-wait-timeout-specific exception parity @@ -54,7 +55,7 @@ Intentional TS/Node Deviations ------------------------------ - Async driver contracts returning `Promise` values -- Node-driver adapters (`mysql2`, `mssql`) instead of PDO-style drivers +- Node-driver adapters (`mysql2`, `mssql`, `pg`, `sqlite3`) instead of PDO-style drivers - Package subpath exports for grouped APIs: - `@devscast/datazen/driver` - `@devscast/datazen/platforms` diff --git a/docs/platforms.md b/docs/platforms.md index 4376873..d8e1373 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -18,10 +18,7 @@ const platform = conn.getDatabasePlatform(); `Connection` resolves the platform in this order: 1. `params.platform` when it is an `AbstractPlatform` instance -2. `driver.getDatabasePlatform()` when provided by the active driver -3. Driver-name fallback (`mysql2` -> `MySQLPlatform`, `mssql` -> `SQLServerPlatform`) - -If none of these applies, Datazen throws a DBAL exception. +2. `driver.getDatabasePlatform(versionProvider)` provided by the active driver Available Platform Classes -------------------------- @@ -29,7 +26,17 @@ Available Platform Classes Currently implemented in this port: - `MySQLPlatform` +- `MySQL80Platform` +- `MySQL84Platform` +- `MariaDBPlatform` +- `MariaDB1052Platform` +- `MariaDB1060Platform` +- `MariaDB1010Platform` +- `MariaDB110700Platform` - `SQLServerPlatform` +- `PostgreSQLPlatform` +- `PostgreSQL120Platform` +- `SQLitePlatform` - `OraclePlatform` - `DB2Platform` - `AbstractMySQLPlatform` (base class) @@ -37,11 +44,15 @@ Currently implemented in this port: Driver defaults: -- `mysql2` driver uses `MySQLPlatform` +- `mysql2` driver uses MySQL/MariaDB platform variants (best effort via configured `serverVersion`) - `mssql` driver uses `SQLServerPlatform` +- `pg` driver uses PostgreSQL platform variants (best effort via configured `serverVersion`) +- `sqlite3` driver uses `SQLitePlatform` -Unlike Doctrine DBAL, version-specific platform subclasses and automatic server -version-based platform switching are not implemented yet. +Unlike Doctrine DBAL, full automatic platform detection from a live async server +connection is not implemented yet. Versioned platform selection in Datazen is +best effort and primarily driven by configured `serverVersion` / +`primary.serverVersion`. What Platforms Are Responsible For ---------------------------------- @@ -151,4 +162,4 @@ Not Implemented Schema-manager-driven platform features are available in this port, but full Doctrine parity is still incomplete across vendors and version-specific -platform variants. +platform variants and runtime detection behavior. diff --git a/docs/supporting-other-databases.md b/docs/supporting-other-databases.md index 5b5d2ed..7fcaff7 100644 --- a/docs/supporting-other-databases.md +++ b/docs/supporting-other-databases.md @@ -8,8 +8,9 @@ Current port scope note ----------------------- Datazen currently focuses on runtime DBAL concerns (driver, connection, -platform, query execution, type conversion). The Doctrine Schema module is not -ported yet, so there is no `SchemaManager` implementation step in this port. +platform, query execution, type conversion), and now includes a partial Schema +module. If you are adding runtime support only, you can ship a driver/platform +without schema-manager parity first. What to implement ----------------- @@ -24,6 +25,8 @@ For a new database platform (new vendor/dialect), also implement: - `AbstractPlatform` subclass (`src/platforms/*`) - vendor type mappings and SQL generation overrides +- optionally, a vendor `AbstractSchemaManager` subclass (`src/schema/*`) if you + want schema-introspection parity Driver/Connection contracts --------------------------- @@ -79,7 +82,9 @@ Path B: New vendor/platform 3. Implement `initializeDatazenTypeMappings()` for DB type -> Datazen type names. 4. Add driver adapter as in Path A and return your platform from `Driver#getDatabasePlatform()`. -5. Export the platform from `src/platforms/index.ts` and `src/index.ts`. +5. (Optional, schema parity) add a vendor schema manager and return it from the + platform's `createSchemaManager(connection)` implementation. +6. Export the platform from `src/platforms/index.ts` and `src/index.ts`. Connection parameters --------------------- diff --git a/index.ts b/index.ts deleted file mode 100644 index e910bb0..0000000 --- a/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./src/index"; diff --git a/src/__tests__/connection/connection-data-manipulation.test.ts b/src/__tests__/connection/connection-data-manipulation.test.ts index 05647ff..c69a476 100644 --- a/src/__tests__/connection/connection-data-manipulation.test.ts +++ b/src/__tests__/connection/connection-data-manipulation.test.ts @@ -1,21 +1,17 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; -import { DriverException } from "../../exception/index"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; +import { DriverException } from "../../exception/driver-exception"; import { ParameterType } from "../../parameter-type"; import { MySQLPlatform } from "../../platforms/mysql-platform"; -import type { CompiledQuery } from "../../types"; +import type { CompiledQuery } from "./query"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -32,13 +28,60 @@ class NoopExceptionConverter implements ExceptionConverter { class CaptureConnection implements DriverConnection { public latestStatement: CompiledQuery | null = null; - public async executeQuery(_query: CompiledQuery): Promise { - return { rows: [] }; + public async prepare(sql: string) { + const boundValues = new Map(); + const boundTypes = new Map(); + + return { + bindValue: (param: string | number, value: unknown, type?: ParameterType) => { + boundValues.set(param, value); + boundTypes.set(param, type); + }, + execute: async () => { + const stringKeys = [...boundValues.keys()].filter( + (key): key is string => typeof key === "string", + ); + + if (stringKeys.length > 0) { + this.latestStatement = { + sql, + parameters: Object.fromEntries(stringKeys.map((key) => [key, boundValues.get(key)])), + types: Object.fromEntries( + stringKeys.map((key) => [key, boundTypes.get(key) ?? ParameterType.STRING]), + ), + }; + } else { + const numericKeys = [...boundValues.keys()] + .filter((key): key is number => typeof key === "number") + .sort((a, b) => a - b); + + this.latestStatement = { + sql, + parameters: numericKeys.map((key) => boundValues.get(key)), + types: numericKeys.map((key) => boundTypes.get(key) ?? ParameterType.STRING), + }; + } + + return new ArrayResult([], [], 1); + }, + }; + } + + public async query(_sql: string) { + return new ArrayResult([]); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(sql: string): Promise { + this.latestStatement = { sql, parameters: [], types: [] }; + return 1; } - public async executeStatement(query: CompiledQuery): Promise { - this.latestStatement = query; - return { affectedRows: 1, insertId: 1 }; + public async lastInsertId(): Promise { + return 1; } public async beginTransaction(): Promise {} diff --git a/src/__tests__/connection/connection-database-platform-version-provider.test.ts b/src/__tests__/connection/connection-database-platform-version-provider.test.ts index fc44364..bfa9e81 100644 --- a/src/__tests__/connection/connection-database-platform-version-provider.test.ts +++ b/src/__tests__/connection/connection-database-platform-version-provider.test.ts @@ -1,22 +1,17 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { StaticServerVersionProvider } from "../../connection/static-server-version-provider"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; -import { DriverException } from "../../exception/index"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; +import { DriverException } from "../../exception/driver-exception"; import { MySQLPlatform } from "../../platforms/mysql-platform"; import type { ServerVersionProvider } from "../../server-version-provider"; -import { StaticServerVersionProvider } from "../../static-server-version-provider"; -import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -31,12 +26,27 @@ class NoopExceptionConverter implements ExceptionConverter { } class PlatformSpyConnection implements DriverConnection { - public async executeQuery(_query: CompiledQuery): Promise { - return { rows: [] }; + public async prepare(_sql: string) { + return { + bindValue: () => undefined, + execute: async () => new ArrayResult([], [], 0), + }; } - public async executeStatement(_query: CompiledQuery): Promise { - return { affectedRows: 0 }; + public async query(_sql: string) { + return new ArrayResult([], [], 0); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(_sql: string): Promise { + return 0; + } + + public async lastInsertId(): Promise { + return 0; } public async beginTransaction(): Promise {} diff --git a/src/__tests__/connection/connection-exception-conversion.test.ts b/src/__tests__/connection/connection-exception-conversion.test.ts index 87cff4c..e799902 100644 --- a/src/__tests__/connection/connection-exception-conversion.test.ts +++ b/src/__tests__/connection/connection-exception-conversion.test.ts @@ -1,20 +1,16 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; -import { ConnectionException, DbalException, DriverException } from "../../exception/index"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; +import { ConnectionException } from "../../exception/connection-exception"; +import { DriverException } from "../../exception/driver-exception"; +import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; import { MySQLPlatform } from "../../platforms/mysql-platform"; -import type { CompiledQuery } from "../../types"; class SpyExceptionConverter implements ExceptionConverter { public lastContext: ExceptionConverterContext | undefined; @@ -37,11 +33,23 @@ class SpyExceptionConverter implements ExceptionConverter { } class ThrowingConnection implements DriverConnection { - public async executeQuery(_query: CompiledQuery): Promise { + public async prepare(_sql: string) { throw new Error("driver query failure"); } - public async executeStatement(_query: CompiledQuery): Promise { + public async query(_sql: string) { + throw new Error("driver query failure"); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(_sql: string): Promise { + throw new Error("driver statement failure"); + } + + public async lastInsertId(): Promise { throw new Error("driver statement failure"); } @@ -69,28 +77,40 @@ class ThrowingConnection implements DriverConnection { } class PassThroughConnection implements DriverConnection { - public async executeQuery(_query: CompiledQuery): Promise { - throw new DbalException("already normalized"); + public async prepare(_sql: string) { + throw new InvalidParameterException("already normalized"); } - public async executeStatement(_query: CompiledQuery): Promise { - throw new DbalException("already normalized"); + public async query(_sql: string) { + throw new InvalidParameterException("already normalized"); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(_sql: string): Promise { + throw new InvalidParameterException("already normalized"); + } + + public async lastInsertId(): Promise { + throw new InvalidParameterException("already normalized"); } public async beginTransaction(): Promise { - throw new DbalException("already normalized"); + throw new InvalidParameterException("already normalized"); } public async commit(): Promise { - throw new DbalException("already normalized"); + throw new InvalidParameterException("already normalized"); } public async rollBack(): Promise { - throw new DbalException("already normalized"); + throw new InvalidParameterException("already normalized"); } public async getServerVersion(): Promise { - throw new DbalException("already normalized"); + throw new InvalidParameterException("already normalized"); } public async close(): Promise {} @@ -140,7 +160,9 @@ describe("Connection exception conversion", () => { it("does not reconvert already normalized DBAL errors", async () => { const connection = new Connection({}, new SpyDriver(new PassThroughConnection())); - await expect(connection.executeQuery("SELECT 1")).rejects.toBeInstanceOf(DbalException); + await expect(connection.executeQuery("SELECT 1")).rejects.toBeInstanceOf( + InvalidParameterException, + ); await expect(connection.executeQuery("SELECT 1")).rejects.not.toBeInstanceOf( ConnectionException, ); diff --git a/src/__tests__/connection/connection-parameter-compilation.test.ts b/src/__tests__/connection/connection-parameter-compilation.test.ts index b4bca5c..b84c521 100644 --- a/src/__tests__/connection/connection-parameter-compilation.test.ts +++ b/src/__tests__/connection/connection-parameter-compilation.test.ts @@ -1,21 +1,17 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; -import { DriverException, MixedParameterStyleException } from "../../exception/index"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; +import { DriverException } from "../../exception/driver-exception"; import { ParameterType } from "../../parameter-type"; import { MySQLPlatform } from "../../platforms/mysql-platform"; -import type { CompiledQuery } from "../../types"; +import type { CompiledQuery } from "./query"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -32,13 +28,62 @@ class NoopExceptionConverter implements ExceptionConverter { class CaptureConnection implements DriverConnection { public latestQuery: CompiledQuery | null = null; - public async executeQuery(query: CompiledQuery): Promise { - this.latestQuery = query; - return { rows: [] }; + public async prepare(sql: string) { + const boundValues = new Map(); + const boundTypes = new Map(); + + return { + bindValue: (param: string | number, value: unknown, type?: ParameterType) => { + boundValues.set(param, value); + boundTypes.set(param, type); + }, + execute: async () => { + const stringKeys = [...boundValues.keys()].filter( + (key): key is string => typeof key === "string", + ); + + if (stringKeys.length > 0) { + const parameters: Record = {}; + const types: Record = {}; + + for (const key of stringKeys) { + parameters[key] = boundValues.get(key); + types[key] = boundTypes.get(key) ?? ParameterType.STRING; + } + + this.latestQuery = { sql, parameters, types }; + } else { + const numericKeys = [...boundValues.keys()] + .filter((key): key is number => typeof key === "number") + .sort((a, b) => a - b); + + const parameters = numericKeys.map((key) => boundValues.get(key)); + const types = numericKeys.map((key) => boundTypes.get(key) ?? ParameterType.STRING); + + this.latestQuery = { sql, parameters, types }; + } + + return new ArrayResult([]); + }, + }; + } + + public async query(sql: string) { + this.latestQuery = { sql, parameters: [], types: [] }; + return new ArrayResult([]); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(sql: string): Promise { + this.latestQuery = { sql, parameters: [], types: [] }; + return 1; } - public async executeStatement(_query: CompiledQuery): Promise { - return { affectedRows: 1 }; + public async lastInsertId(): Promise { + return 1; } public async beginTransaction(): Promise {} @@ -120,15 +165,26 @@ describe("Connection parameter compilation", () => { }); }); - it("throws on mixed placeholder styles in the same SQL", async () => { - const connection = new Connection({}, new NamedSpyDriver(new CaptureConnection())); + it("compiles mixed placeholder styles without throwing", async () => { + const capture = new CaptureConnection(); + const connection = new Connection({}, new NamedSpyDriver(capture)); - await expect( - connection.executeQuery( - "SELECT * FROM users WHERE id = :id AND parent_id = ?", - { id: 1 }, - { id: ParameterType.INTEGER }, - ), - ).rejects.toThrow(MixedParameterStyleException); + await connection.executeQuery( + "SELECT * FROM users WHERE id = :id AND parent_id = ?", + { id: 1, 0: 2 }, + { id: ParameterType.INTEGER, 0: ParameterType.INTEGER }, + ); + + expect(capture.latestQuery).toEqual({ + parameters: { + p1: 1, + p2: 2, + }, + sql: "SELECT * FROM users WHERE id = @p1 AND parent_id = @p2", + types: { + p1: ParameterType.INTEGER, + p2: ParameterType.INTEGER, + }, + }); }); }); diff --git a/src/__tests__/connection/connection-transaction.test.ts b/src/__tests__/connection/connection-transaction.test.ts index 7b8f14a..893f866 100644 --- a/src/__tests__/connection/connection-transaction.test.ts +++ b/src/__tests__/connection/connection-transaction.test.ts @@ -2,25 +2,19 @@ import { describe, expect, it } from "vitest"; import { Configuration } from "../../configuration"; import { Connection } from "../../connection"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; -import { - DriverException, - NestedTransactionsNotSupportedException, - NoActiveTransactionException, - RollbackOnlyException, -} from "../../exception/index"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; +import { CommitFailedRollbackOnly } from "../../exception/commit-failed-rollback-only"; +import { DriverException } from "../../exception/driver-exception"; +import { NoActiveTransaction } from "../../exception/no-active-transaction"; +import { SavepointsNotSupported } from "../../exception/savepoints-not-supported"; +import { AbstractPlatform } from "../../platforms/abstract-platform"; import { MySQLPlatform } from "../../platforms/mysql-platform"; -import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -34,65 +28,37 @@ class NoopExceptionConverter implements ExceptionConverter { } } -interface SavepointSupport { - create: boolean; - release: boolean; - rollback: boolean; -} - class SpyDriverConnection implements DriverConnection { public beginCalls = 0; public commitCalls = 0; public rollbackCalls = 0; public closeCalls = 0; - public createSavepointCalls: string[] = []; - public releaseSavepointCalls: string[] = []; - public rollbackSavepointCalls: string[] = []; - - public createSavepoint?: (name: string) => Promise; - public releaseSavepoint?: (name: string) => Promise; - public rollbackSavepoint?: (name: string) => Promise; - public quote?: (value: string) => string; + public execCalls: string[] = []; - constructor( - savepointSupport: SavepointSupport = { create: true, release: true, rollback: true }, - quote?: (value: string) => string, - ) { - if (savepointSupport.create) { - this.createSavepoint = async (name: string): Promise => { - this.createSavepointCalls.push(name); - }; - } - - if (savepointSupport.release) { - this.releaseSavepoint = async (name: string): Promise => { - this.releaseSavepointCalls.push(name); - }; - } - - if (savepointSupport.rollback) { - this.rollbackSavepoint = async (name: string): Promise => { - this.rollbackSavepointCalls.push(name); - }; - } - - if (quote !== undefined) { - this.quote = quote; - } - } + constructor(private readonly quoteImpl?: (value: string) => string) {} - public async executeQuery(_query: CompiledQuery): Promise { + public async prepare(_sql: string) { return { - rowCount: 1, - rows: [{ value: 1 }], + bindValue: () => undefined, + execute: async () => new ArrayResult([], [], 1), }; } - public async executeStatement(_query: CompiledQuery): Promise { - return { - affectedRows: 1, - insertId: 123, - }; + public async query(_sql: string) { + return new ArrayResult([{ value: 1 }], ["value"], 1); + } + + public quote(value: string): string { + return this.quoteImpl?.(value) ?? `'${value.replace(/'/g, "''")}'`; + } + + public async exec(sql: string): Promise { + this.execCalls.push(sql); + return 1; + } + + public async lastInsertId(): Promise { + return 123; } public async beginTransaction(): Promise { @@ -120,14 +86,28 @@ class SpyDriverConnection implements DriverConnection { } } +class NoSavepointPlatform extends MySQLPlatform { + public override supportsSavepoints(): boolean { + return false; + } +} + +class NoReleaseSavepointPlatform extends MySQLPlatform { + public override supportsReleaseSavepoints(): boolean { + return false; + } +} + class MultiColumnDriverConnection extends SpyDriverConnection { - public override async executeQuery(_query: CompiledQuery): Promise { - return { - rows: [ + public override async query(_sql: string) { + return new ArrayResult( + [ { id: "u1", name: "Alice", active: true }, { id: "u2", name: "Bob", active: false }, ], - }; + ["id", "name", "active"], + 2, + ); } } @@ -137,7 +117,10 @@ class SpyDriver implements Driver { public connectCalls = 0; private readonly exceptionConverter = new NoopExceptionConverter(); - constructor(private readonly connection: DriverConnection) {} + constructor( + private readonly connection: DriverConnection, + private readonly platform: AbstractPlatform = new MySQLPlatform(), + ) {} public async connect(_params: Record): Promise { this.connectCalls += 1; @@ -148,8 +131,8 @@ class SpyDriver implements Driver { return this.exceptionConverter; } - public getDatabasePlatform(): MySQLPlatform { - return new MySQLPlatform(); + public getDatabasePlatform(): AbstractPlatform { + return this.platform; } } @@ -203,12 +186,15 @@ describe("Connection transactions and state", () => { await connection.beginTransaction(); expect(connection.getTransactionNestingLevel()).toBe(2); - expect(driverConnection.createSavepointCalls).toEqual(["DATAZEN_2"]); + expect(driverConnection.execCalls).toEqual(["SAVEPOINT DATAZEN_2"]); await connection.commit(); expect(connection.getTransactionNestingLevel()).toBe(1); - expect(driverConnection.releaseSavepointCalls).toEqual(["DATAZEN_2"]); + expect(driverConnection.execCalls).toEqual([ + "SAVEPOINT DATAZEN_2", + "RELEASE SAVEPOINT DATAZEN_2", + ]); }); it("rolls back nested transactions to savepoint", async () => { @@ -220,7 +206,10 @@ describe("Connection transactions and state", () => { await connection.rollBack(); expect(connection.getTransactionNestingLevel()).toBe(1); - expect(driverConnection.rollbackSavepointCalls).toEqual(["DATAZEN_2"]); + expect(driverConnection.execCalls).toEqual([ + "SAVEPOINT DATAZEN_2", + "ROLLBACK TO SAVEPOINT DATAZEN_2", + ]); await connection.rollBack(); expect(connection.getTransactionNestingLevel()).toBe(0); @@ -228,50 +217,33 @@ describe("Connection transactions and state", () => { }); it("throws when nested transactions are not supported", async () => { - const driverConnection = new SpyDriverConnection({ - create: false, - release: false, - rollback: false, - }); - const connection = new Connection({}, new SpyDriver(driverConnection)); - - await connection.beginTransaction(); - await expect(connection.beginTransaction()).rejects.toThrow( - NestedTransactionsNotSupportedException, + const driverConnection = new SpyDriverConnection(); + const connection = new Connection( + {}, + new SpyDriver(driverConnection, new NoSavepointPlatform()), ); - }); - it("throws when commit savepoints are not supported", async () => { - const driverConnection = new SpyDriverConnection({ - create: true, - release: false, - rollback: true, - }); - const connection = new Connection({}, new SpyDriver(driverConnection)); - - await connection.beginTransaction(); await connection.beginTransaction(); - await expect(connection.commit()).rejects.toThrow(NestedTransactionsNotSupportedException); + await expect(connection.beginTransaction()).rejects.toThrow(SavepointsNotSupported); }); - it("throws when rollback savepoints are not supported", async () => { - const driverConnection = new SpyDriverConnection({ - create: true, - release: true, - rollback: false, - }); - const connection = new Connection({}, new SpyDriver(driverConnection)); + it("throws when commit savepoints are not supported", async () => { + const driverConnection = new SpyDriverConnection(); + const connection = new Connection( + {}, + new SpyDriver(driverConnection, new NoReleaseSavepointPlatform()), + ); await connection.beginTransaction(); await connection.beginTransaction(); - await expect(connection.rollBack()).rejects.toThrow(NestedTransactionsNotSupportedException); + await expect(connection.commit()).rejects.toThrow(SavepointsNotSupported); }); it("throws for commit/rollback when there is no active transaction", async () => { const connection = new Connection({}, new SpyDriver(new SpyDriverConnection())); - await expect(connection.commit()).rejects.toThrow(NoActiveTransactionException); - await expect(connection.rollBack()).rejects.toThrow(NoActiveTransactionException); + await expect(connection.commit()).rejects.toThrow(NoActiveTransaction); + await expect(connection.rollBack()).rejects.toThrow(NoActiveTransaction); }); it("supports rollback-only state and blocks commit", async () => { @@ -280,15 +252,15 @@ describe("Connection transactions and state", () => { await connection.beginTransaction(); connection.setRollbackOnly(); expect(connection.isRollbackOnly()).toBe(true); - await expect(connection.commit()).rejects.toThrow(RollbackOnlyException); + await expect(connection.commit()).rejects.toThrow(CommitFailedRollbackOnly); await connection.rollBack(); }); it("throws rollback-only checks when not in a transaction", () => { const connection = new Connection({}, new SpyDriver(new SpyDriverConnection())); - expect(() => connection.setRollbackOnly()).toThrow(NoActiveTransactionException); - expect(() => connection.isRollbackOnly()).toThrow(NoActiveTransactionException); + expect(() => connection.setRollbackOnly()).toThrow(NoActiveTransaction); + expect(() => connection.isRollbackOnly()).toThrow(NoActiveTransaction); }); it("commits or rolls back through transactional()", async () => { @@ -339,7 +311,10 @@ describe("Connection transactions and state", () => { expect(connection.getTransactionNestingLevel()).toBe(1); expect(driverConnection.beginCalls).toBe(1); - expect(driverConnection.releaseSavepointCalls).toEqual(["DATAZEN_2"]); + expect(driverConnection.execCalls).toEqual([ + "SAVEPOINT DATAZEN_2", + "RELEASE SAVEPOINT DATAZEN_2", + ]); }); it("restarts the root transaction on rollback when auto-commit is disabled", async () => { @@ -404,7 +379,7 @@ describe("Connection transactions and state", () => { it("uses driver quote when available and fallback otherwise", async () => { const withQuote = new Connection( {}, - new SpyDriver(new SpyDriverConnection(undefined, (value) => `[${value}]`)), + new SpyDriver(new SpyDriverConnection((value) => `[${value}]`)), ); const withoutQuote = new Connection({}, new SpyDriver(new SpyDriverConnection())); diff --git a/src/__tests__/connection/connection-type-conversion.test.ts b/src/__tests__/connection/connection-type-conversion.test.ts index 9d4da75..731970c 100644 --- a/src/__tests__/connection/connection-type-conversion.test.ts +++ b/src/__tests__/connection/connection-type-conversion.test.ts @@ -2,23 +2,20 @@ import { describe, expect, it } from "vitest"; import { ArrayParameterType } from "../../array-parameter-type"; import { Connection } from "../../connection"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverException } from "../../exception/driver-exception"; import { ParameterType } from "../../parameter-type"; import { MySQLPlatform } from "../../platforms/mysql-platform"; -import type { CompiledQuery } from "../../types"; import { DateType } from "../../types/date-type"; +import { registerBuiltInTypes } from "../../types/register-built-in-types"; import { Types } from "../../types/types"; +import type { CompiledQuery } from "./query"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -36,14 +33,67 @@ class CaptureConnection implements DriverConnection { public latestQuery: CompiledQuery | null = null; public latestStatement: CompiledQuery | null = null; - public async executeQuery(query: CompiledQuery): Promise { - this.latestQuery = query; - return { rows: [{ ok: true }] }; + public async prepare(sql: string) { + const boundValues = new Map(); + const boundTypes = new Map(); + + return { + bindValue: (param: string | number, value: unknown, type?: ParameterType) => { + boundValues.set(param, value); + boundTypes.set(param, type); + }, + execute: async () => { + const numericKeys = [...boundValues.keys()] + .filter((key): key is number => typeof key === "number") + .sort((a, b) => a - b); + const stringKeys = [...boundValues.keys()].filter( + (key): key is string => typeof key === "string", + ); + + const compiled = + stringKeys.length > 0 + ? { + sql, + parameters: Object.fromEntries( + stringKeys.map((key) => [key, boundValues.get(key)]), + ), + types: Object.fromEntries( + stringKeys.map((key) => [key, boundTypes.get(key) ?? ParameterType.STRING]), + ), + } + : { + sql, + parameters: numericKeys.map((key) => boundValues.get(key)), + types: numericKeys.map((key) => boundTypes.get(key) ?? ParameterType.STRING), + }; + + if (/^\s*select\b/i.test(sql)) { + this.latestQuery = compiled; + return new ArrayResult([{ ok: true }], ["ok"], 1); + } + + this.latestStatement = compiled; + return new ArrayResult([], [], 1); + }, + }; } - public async executeStatement(query: CompiledQuery): Promise { - this.latestStatement = query; - return { affectedRows: 1, insertId: null }; + public async query(sql: string) { + this.latestQuery = { sql, parameters: [], types: [] }; + return new ArrayResult([{ ok: true }], ["ok"], 1); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(sql: string): Promise { + this.latestStatement = { sql, parameters: [], types: [] }; + return 1; + } + + public async lastInsertId(): Promise { + return 1; } public async beginTransaction(): Promise {} @@ -79,6 +129,8 @@ class SpyDriver implements Driver { } describe("Connection type conversion", () => { + registerBuiltInTypes(); + it("converts Datazen Type names to driver values and binding types", async () => { const capture = new CaptureConnection(); const connection = new Connection({}, new SpyDriver(capture)); diff --git a/src/__tests__/connection/connection-typed-fetch.test.ts b/src/__tests__/connection/connection-typed-fetch.test.ts index c283786..1028c52 100644 --- a/src/__tests__/connection/connection-typed-fetch.test.ts +++ b/src/__tests__/connection/connection-typed-fetch.test.ts @@ -1,20 +1,15 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; -import { DriverException } from "../../exception/index"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; +import { DriverException } from "../../exception/driver-exception"; import { MySQLPlatform } from "../../platforms/mysql-platform"; -import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -31,12 +26,27 @@ class NoopExceptionConverter implements ExceptionConverter { class StaticRowsConnection implements DriverConnection { public constructor(private readonly rows: Array>) {} - public async executeQuery(_query: CompiledQuery): Promise { - return { rows: [...this.rows] }; + public async prepare(_sql: string) { + return { + bindValue: () => undefined, + execute: async () => new ArrayResult([...this.rows]), + }; } - public async executeStatement(_query: CompiledQuery): Promise { - return { affectedRows: 0 }; + public async query(_sql: string) { + return new ArrayResult([...this.rows]); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(_sql: string): Promise { + return 0; + } + + public async lastInsertId(): Promise { + return 0; } public async beginTransaction(): Promise {} diff --git a/src/__tests__/connection/primary-read-replica-connection.test.ts b/src/__tests__/connection/primary-read-replica-connection.test.ts new file mode 100644 index 0000000..502cc45 --- /dev/null +++ b/src/__tests__/connection/primary-read-replica-connection.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; + +import type { Driver, DriverConnection } from "../../driver"; +import type { + ExceptionConverter, + ExceptionConverterContext, +} from "../../driver/api/exception-converter"; +import { ArrayResult } from "../../driver/array-result"; +import { DriverManager } from "../../driver-manager"; +import { DriverException } from "../../exception/driver-exception"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; + +class NoopExceptionConverter implements ExceptionConverter { + public convert(error: unknown, context: ExceptionConverterContext): DriverException { + return new DriverException("driver error", { + cause: error, + driverName: "primary-replica-spy", + operation: context.operation, + parameters: context.query?.parameters, + sql: context.query?.sql, + }); + } +} + +class SpyPhysicalConnection implements DriverConnection { + public readonly querySql: string[] = []; + public readonly execSql: string[] = []; + public beginCalls = 0; + public commitCalls = 0; + public rollbackCalls = 0; + public closeCalls = 0; + + constructor(public readonly role: string) {} + + public async prepare(sql: string) { + return { + bindValue: () => undefined, + execute: async () => { + this.execSql.push(sql); + return new ArrayResult([], [], 1); + }, + }; + } + + public async query(sql: string) { + this.querySql.push(sql); + return new ArrayResult([{ value: this.role }], ["value"], 1); + } + + public quote(value: string): string { + return `[${this.role}:${value}]`; + } + + public async exec(sql: string): Promise { + this.execSql.push(sql); + return 1; + } + + public async lastInsertId(): Promise { + return 1; + } + + public async beginTransaction(): Promise { + this.beginCalls += 1; + } + + public async commit(): Promise { + this.commitCalls += 1; + } + + public async rollBack(): Promise { + this.rollbackCalls += 1; + } + + public async getServerVersion(): Promise { + return "8.0.36"; + } + + public async close(): Promise { + this.closeCalls += 1; + } + + public getNativeConnection(): unknown { + return { role: this.role }; + } +} + +class SpyDriver implements Driver { + public readonly connectParams: Array> = []; + public readonly connections: SpyPhysicalConnection[] = []; + private readonly converter = new NoopExceptionConverter(); + + public async connect(params: Record): Promise { + this.connectParams.push(params); + const role = String(params.role ?? `conn-${this.connections.length + 1}`); + const connection = new SpyPhysicalConnection(role); + this.connections.push(connection); + return connection; + } + + public getExceptionConverter(): ExceptionConverter { + return this.converter; + } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } +} + +function createPrimaryReplicaConnection(driver: SpyDriver, keepReplica = false) { + return DriverManager.getPrimaryReadReplicaConnection({ + driverInstance: driver, + keepReplica, + primary: { role: "primary" }, + replica: [{ role: "replica" }], + }); +} + +describe("PrimaryReadReplicaConnection", () => { + it("uses replica on connect helpers until primary is explicitly requested", async () => { + const driver = new SpyDriver(); + const connection = createPrimaryReplicaConnection(driver); + + expect(connection.isConnectedToPrimary()).toBe(false); + + await connection.ensureConnectedToReplica(); + expect(connection.isConnectedToPrimary()).toBe(false); + + await connection.ensureConnectedToPrimary(); + expect(connection.isConnectedToPrimary()).toBe(true); + + expect(driver.connectParams.map((params) => params.role)).toEqual(["replica", "primary"]); + }); + + it("does not switch to primary on executeQuery-style reads", async () => { + const driver = new SpyDriver(); + const connection = createPrimaryReplicaConnection(driver); + + await expect(connection.fetchOne("SELECT 1")).resolves.toBe("replica"); + + expect(connection.isConnectedToPrimary()).toBe(false); + expect(driver.connectParams.map((params) => params.role)).toEqual(["replica"]); + expect(driver.connections[0]?.querySql).toEqual(["SELECT 1"]); + }); + + it("switches to primary on write operations and stays there for later reads", async () => { + const driver = new SpyDriver(); + const connection = createPrimaryReplicaConnection(driver); + + await expect(connection.fetchOne("SELECT 1")).resolves.toBe("replica"); + expect(connection.isConnectedToPrimary()).toBe(false); + + await expect(connection.executeStatement("UPDATE users SET active = 1")).resolves.toBe(1); + expect(connection.isConnectedToPrimary()).toBe(true); + + await expect(connection.fetchOne("SELECT 2")).resolves.toBe("primary"); + + expect(driver.connectParams.map((params) => params.role)).toEqual(["replica", "primary"]); + expect(driver.connections[0]?.querySql).toEqual(["SELECT 1"]); + expect(driver.connections[1]?.execSql).toEqual(["UPDATE users SET active = 1"]); + expect(driver.connections[1]?.querySql).toEqual(["SELECT 2"]); + }); + + it("pins transactions and nested savepoints to primary", async () => { + const driver = new SpyDriver(); + const connection = createPrimaryReplicaConnection(driver); + + await connection.beginTransaction(); + await connection.beginTransaction(); + await expect(connection.fetchOne("SELECT 1")).resolves.toBe("primary"); + await connection.commit(); + await connection.rollBack(); + + expect(driver.connectParams.map((params) => params.role)).toEqual(["primary"]); + expect(driver.connections[0]?.beginCalls).toBe(1); + expect(driver.connections[0]?.execSql).toEqual([ + "SAVEPOINT DATAZEN_2", + "RELEASE SAVEPOINT DATAZEN_2", + ]); + expect(driver.connections[0]?.rollbackCalls).toBe(1); + }); + + it("supports explicit switching helpers and keepReplica=true switching back", async () => { + const driver = new SpyDriver(); + const connection = createPrimaryReplicaConnection(driver, true); + + await connection.ensureConnectedToReplica(); + expect(connection.isConnectedToPrimary()).toBe(false); + await expect(connection.getNativeConnection()).resolves.toEqual({ role: "replica" }); + + await connection.ensureConnectedToPrimary(); + expect(connection.isConnectedToPrimary()).toBe(true); + await expect(connection.quote("abc")).resolves.toBe("[primary:abc]"); + + await connection.ensureConnectedToReplica(); + expect(connection.isConnectedToPrimary()).toBe(false); + await expect(connection.getNativeConnection()).resolves.toEqual({ role: "replica" }); + }); + + it("with keepReplica=true stays on primary after transaction write until replica is explicitly requested", async () => { + const driver = new SpyDriver(); + const connection = createPrimaryReplicaConnection(driver, true); + + await connection.ensureConnectedToReplica(); + expect(connection.isConnectedToPrimary()).toBe(false); + + await connection.beginTransaction(); + await connection.executeStatement("INSERT INTO users(id) VALUES (1)"); + await connection.commit(); + + expect(connection.isConnectedToPrimary()).toBe(true); + + await connection.connect(); + expect(connection.isConnectedToPrimary()).toBe(true); + + await connection.ensureConnectedToReplica(); + expect(connection.isConnectedToPrimary()).toBe(false); + }); + + it("with keepReplica=true stays on primary after insert until replica is explicitly requested", async () => { + const driver = new SpyDriver(); + const connection = createPrimaryReplicaConnection(driver, true); + + await connection.ensureConnectedToReplica(); + expect(connection.isConnectedToPrimary()).toBe(false); + + await connection.insert("users", { id: 30 }); + expect(connection.isConnectedToPrimary()).toBe(true); + + await connection.connect(); + expect(connection.isConnectedToPrimary()).toBe(true); + + await connection.ensureConnectedToReplica(); + expect(connection.isConnectedToPrimary()).toBe(false); + }); + + it("inherits charset from primary when replica charset is missing", async () => { + const driver = new SpyDriver(); + const connection = DriverManager.getPrimaryReadReplicaConnection({ + driverInstance: driver, + primary: { charset: "utf8mb4", role: "primary" }, + replica: [{ role: "replica" }], + }); + + await connection.ensureConnectedToReplica(); + + expect(driver.connectParams[0]).toMatchObject({ charset: "utf8mb4", role: "replica" }); + }); + + it("closes both cached connections and can reconnect", async () => { + const driver = new SpyDriver(); + const connection = createPrimaryReplicaConnection(driver); + + await connection.ensureConnectedToReplica(); + await connection.ensureConnectedToPrimary(); + expect(connection.isConnectedToPrimary()).toBe(true); + + await connection.close(); + expect(driver.connections.map((c) => c.closeCalls)).toEqual([0, 1]); + expect(connection.isConnectedToPrimary()).toBe(false); + + await connection.ensureConnectedToPrimary(); + expect(connection.isConnectedToPrimary()).toBe(true); + expect(driver.connectParams.map((params) => params.role)).toEqual([ + "replica", + "primary", + "primary", + ]); + }); + + it("close resets primary selection and reconnect can choose replica again", async () => { + const driver = new SpyDriver(); + const connection = createPrimaryReplicaConnection(driver); + + await connection.ensureConnectedToPrimary(); + expect(connection.isConnectedToPrimary()).toBe(true); + + await connection.close(); + expect(connection.isConnectedToPrimary()).toBe(false); + + await connection.ensureConnectedToReplica(); + expect(connection.isConnectedToPrimary()).toBe(false); + await expect(connection.fetchOne("SELECT 1")).resolves.toBe("replica"); + + expect(driver.connectParams.map((params) => params.role)).toEqual(["primary", "replica"]); + }); +}); diff --git a/src/__tests__/connection/static-server-version-provider.test.ts b/src/__tests__/connection/static-server-version-provider.test.ts index 044fa1e..049aed6 100644 --- a/src/__tests__/connection/static-server-version-provider.test.ts +++ b/src/__tests__/connection/static-server-version-provider.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { StaticServerVersionProvider } from "../../static-server-version-provider"; +import { StaticServerVersionProvider } from "../../connection/static-server-version-provider"; describe("StaticServerVersionProvider", () => { it("returns the configured server version", async () => { diff --git a/src/__tests__/driver/abstract-db2-driver.test.ts b/src/__tests__/driver/abstract-db2-driver.test.ts new file mode 100644 index 0000000..26af5ab --- /dev/null +++ b/src/__tests__/driver/abstract-db2-driver.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { StaticServerVersionProvider } from "../../connection/static-server-version-provider"; +import type { DriverConnection } from "../../driver"; +import { AbstractDB2Driver } from "../../driver/abstract-db2-driver"; +import { ExceptionConverter as IBMDB2ExceptionConverter } from "../../driver/api/ibmdb2/exception-converter"; +import { DB2Platform } from "../../platforms/db2-platform"; + +class TestDB2Driver extends AbstractDB2Driver { + public async connect(_params: Record): Promise { + throw new Error("not used in this test"); + } +} + +describe("AbstractDB2Driver", () => { + it("returns DB2Platform", () => { + const driver = new TestDB2Driver(); + + expect(driver.getDatabasePlatform(new StaticServerVersionProvider("11.5"))).toBeInstanceOf( + DB2Platform, + ); + }); + + it("returns the IBM DB2 exception converter", () => { + const driver = new TestDB2Driver(); + + expect(driver.getExceptionConverter()).toBeInstanceOf(IBMDB2ExceptionConverter); + }); +}); diff --git a/src/__tests__/driver/abstract-oracle-driver-easy-connect-string.test.ts b/src/__tests__/driver/abstract-oracle-driver-easy-connect-string.test.ts new file mode 100644 index 0000000..434aea4 --- /dev/null +++ b/src/__tests__/driver/abstract-oracle-driver-easy-connect-string.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +import { EasyConnectString } from "../../driver/abstract-oracle-driver/easy-connect-string"; + +describe("EasyConnectString", () => { + it("renders nested array parameters", () => { + const easyConnect = EasyConnectString.fromArray({ + DESCRIPTION: { + ADDRESS: { + PROTOCOL: "TCP", + HOST: "db.example.test", + PORT: 1521, + }, + CONNECT_DATA: { + SID: "XE", + EMPTY_VALUE: null, + ENABLED: true, + DISABLED: false, + }, + }, + }); + + expect(easyConnect.toString()).toBe( + "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.example.test)(PORT=1521))(CONNECT_DATA=(SID=XE)(ENABLED=1)))", + ); + }); + + it("uses connectstring when provided", () => { + const easyConnect = EasyConnectString.fromConnectionParameters({ + connectstring: "(DESCRIPTION=(ADDRESS=(HOST=override)))", + host: "ignored", + dbname: "ignored", + }); + + expect(easyConnect.toString()).toBe("(DESCRIPTION=(ADDRESS=(HOST=override)))"); + }); + + it("falls back to dbname when host is missing", () => { + expect(EasyConnectString.fromConnectionParameters({ dbname: "XE" }).toString()).toBe("XE"); + expect(EasyConnectString.fromConnectionParameters({}).toString()).toBe(""); + }); + + it("builds an oracle descriptor using SID by default", () => { + const easyConnect = EasyConnectString.fromConnectionParameters({ + host: "oracle.local", + dbname: "ORCL", + }); + + expect(easyConnect.toString()).toBe( + "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=oracle.local)(PORT=1521))(CONNECT_DATA=(SID=ORCL)))", + ); + }); + + it("uses SERVICE_NAME mode and emits a deprecation warning for service", () => { + const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined); + + const easyConnect = EasyConnectString.fromConnectionParameters({ + host: "oracle.local", + dbname: "ORCLPDB1", + service: true, + instancename: "ORCL1", + pooled: true, + port: 2484, + driverOptions: { + protocol: "TCPS", + }, + }); + + expect(easyConnect.toString()).toBe( + "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCPS)(HOST=oracle.local)(PORT=2484))(CONNECT_DATA=(SERVICE_NAME=ORCLPDB1)(INSTANCE_NAME=ORCL1)(SERVER=POOLED)))", + ); + expect(emitWarning).toHaveBeenCalledTimes(1); + expect(emitWarning).toHaveBeenCalledWith( + expect.stringContaining('"service" parameter'), + "DeprecationWarning", + ); + + emitWarning.mockRestore(); + }); +}); diff --git a/src/__tests__/driver/abstract-sqlite-driver-enable-foreign-keys.test.ts b/src/__tests__/driver/abstract-sqlite-driver-enable-foreign-keys.test.ts new file mode 100644 index 0000000..fd5ed3d --- /dev/null +++ b/src/__tests__/driver/abstract-sqlite-driver-enable-foreign-keys.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { StaticServerVersionProvider } from "../../connection/static-server-version-provider"; +import { type Driver, type DriverConnection } from "../../driver"; +import { EnableForeignKeys } from "../../driver/abstract-sqlite-driver/middleware/enable-foreign-keys"; +import type { ExceptionConverter } from "../../driver/api/exception-converter"; +import { ExceptionConverter as SQLiteExceptionConverter } from "../../driver/api/sqlite/exception-converter"; +import { ArrayResult } from "../../driver/array-result"; +import { SQLitePlatform } from "../../platforms/sqlite-platform"; + +class SpyConnection implements DriverConnection { + public readonly execStatements: string[] = []; + + public async prepare(_sql: string) { + return { + bindValue: () => undefined, + execute: async () => new ArrayResult([], [], 0), + }; + } + + public async query(_sql: string) { + return new ArrayResult([], [], 0); + } + + public quote(value: string): string { + return value; + } + + public async exec(sql: string): Promise { + this.execStatements.push(sql); + return 0; + } + + public async lastInsertId(): Promise { + return 0; + } + + public async beginTransaction(): Promise {} + public async commit(): Promise {} + public async rollBack(): Promise {} + public async getServerVersion(): Promise { + return "3.45.1"; + } + public async close(): Promise {} + public getNativeConnection(): unknown { + return null; + } +} + +class SpyDriver implements Driver { + public readonly connection = new SpyConnection(); + private readonly converter: ExceptionConverter = new SQLiteExceptionConverter(); + private readonly platform = new SQLitePlatform(); + + public async connect(_params: Record): Promise { + return this.connection; + } + + public getExceptionConverter(): ExceptionConverter { + return this.converter; + } + + public getDatabasePlatform(): SQLitePlatform { + return this.platform; + } +} + +describe("EnableForeignKeys middleware", () => { + it("executes PRAGMA foreign_keys=ON on connect", async () => { + const driver = new SpyDriver(); + const wrapped = new EnableForeignKeys().wrap(driver); + + const connection = await wrapped.connect({}); + + expect(connection).toBe(driver.connection); + expect(driver.connection.execStatements).toEqual(["PRAGMA foreign_keys=ON"]); + }); + + it("preserves driver behavior and platform methods", () => { + const wrapped = new EnableForeignKeys().wrap(new SpyDriver()); + + expect(wrapped.getDatabasePlatform(new StaticServerVersionProvider("3.45.1"))).toBeInstanceOf( + SQLitePlatform, + ); + }); +}); diff --git a/src/__tests__/driver/driver-exception-converter.test.ts b/src/__tests__/driver/driver-exception-converter.test.ts index fe364d8..9f30a58 100644 --- a/src/__tests__/driver/driver-exception-converter.test.ts +++ b/src/__tests__/driver/driver-exception-converter.test.ts @@ -1,16 +1,14 @@ import { describe, expect, it } from "vitest"; import { ExceptionConverter as MySQLExceptionConverter } from "../../driver/api/mysql/exception-converter"; -import { ExceptionConverter as SQLSrvExceptionConverter } from "../../driver/api/sqlsrv/exception-converter"; -import { - ConnectionException, - DeadlockException, - DriverException, - ForeignKeyConstraintViolationException, - NotNullConstraintViolationException, - SqlSyntaxException, - UniqueConstraintViolationException, -} from "../../exception/index"; +import { ExceptionConverter as SQLSrvExceptionConverter } from "../../driver/api/sql-server/exception-converter"; +import { ConnectionException } from "../../exception/connection-exception"; +import { DeadlockException } from "../../exception/deadlock-exception"; +import { DriverException } from "../../exception/driver-exception"; +import { ForeignKeyConstraintViolationException } from "../../exception/foreign-key-constraint-violation-exception"; +import { NotNullConstraintViolationException } from "../../exception/not-null-constraint-violation-exception"; +import { SyntaxErrorException } from "../../exception/syntax-error-exception"; +import { UniqueConstraintViolationException } from "../../exception/unique-constraint-violation-exception"; import { Query } from "../../query"; describe("Driver exception converters", () => { @@ -90,7 +88,7 @@ describe("Driver exception converters", () => { }); expect(converter.convert(syntaxError, { operation: "executeQuery" })).toBeInstanceOf( - SqlSyntaxException, + SyntaxErrorException, ); expect(converter.convert(uniqueError, { operation: "executeStatement" })).toBeInstanceOf( UniqueConstraintViolationException, diff --git a/src/__tests__/driver/driver-exception-parity.test.ts b/src/__tests__/driver/driver-exception-parity.test.ts new file mode 100644 index 0000000..78397a1 --- /dev/null +++ b/src/__tests__/driver/driver-exception-parity.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { AbstractException } from "../../driver/abstract-exception"; +import { IdentityColumnsNotSupported } from "../../driver/exception/identity-columns-not-supported"; +import { NoIdentityValue } from "../../driver/exception/no-identity-value"; + +class TestDriverException extends AbstractException {} + +describe("Driver exception parity", () => { + it("stores SQL state and code", () => { + const cause = new Error("boom"); + const error = new TestDriverException("driver error", "08006", 123, cause); + + expect(error.message).toBe("driver error"); + expect(error.code).toBe(123); + expect(error.getSQLState()).toBe("08006"); + expect((error as Error & { cause?: unknown }).cause).toBe(cause); + }); + + it("builds IdentityColumnsNotSupported factory exception", () => { + const error = IdentityColumnsNotSupported.new(); + expect(error.message).toBe("The driver does not support identity columns."); + expect(error.getSQLState()).toBeNull(); + }); + + it("builds NoIdentityValue factory exception", () => { + const error = NoIdentityValue.new(); + expect(error.message).toBe("No identity value was generated by the last statement."); + expect(error.getSQLState()).toBeNull(); + }); +}); diff --git a/src/__tests__/driver/driver-manager.test.ts b/src/__tests__/driver/driver-manager.test.ts index 0e7b491..35f4bf8 100644 --- a/src/__tests__/driver/driver-manager.test.ts +++ b/src/__tests__/driver/driver-manager.test.ts @@ -1,26 +1,17 @@ import { describe, expect, it } from "vitest"; import { Configuration } from "../../configuration"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverMiddleware, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection, type DriverMiddleware } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; +import { ArrayResult } from "../../driver/array-result"; import { DriverManager } from "../../driver-manager"; -import { - DriverException, - DriverRequiredException, - UnknownDriverException, -} from "../../exception/index"; +import { DriverException } from "../../exception/driver-exception"; +import { DriverRequired } from "../../exception/driver-required"; +import { UnknownDriver } from "../../exception/unknown-driver"; import { MySQLPlatform } from "../../platforms/mysql-platform"; -import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -35,12 +26,27 @@ class NoopExceptionConverter implements ExceptionConverter { } class SpyConnection implements DriverConnection { - public async executeQuery(_query: CompiledQuery): Promise { - return { rows: [] }; + public async prepare(_sql: string) { + return { + bindValue: () => undefined, + execute: async () => new ArrayResult([], [], 0), + }; + } + + public async query(_sql: string) { + return new ArrayResult([], [], 0); + } + + public quote(value: string): string { + return value; } - public async executeStatement(_query: CompiledQuery): Promise { - return { affectedRows: 0 }; + public async exec(_sql: string): Promise { + return 0; + } + + public async lastInsertId(): Promise { + return 0; } public async beginTransaction(): Promise {} @@ -56,8 +62,6 @@ class SpyConnection implements DriverConnection { } class SpyDriver implements Driver { - public readonly name = "spy"; - public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; public connectCalls = 0; private readonly converter = new NoopExceptionConverter(); @@ -76,9 +80,6 @@ class SpyDriver implements Driver { } class NeverUseDriver implements Driver { - public readonly name = "never"; - public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; - public async connect(_params: Record): Promise { throw new Error("driverClass should not be used when driverInstance is provided"); } @@ -99,15 +100,18 @@ class PrefixMiddleware implements DriverMiddleware { const prefix = this.prefix; return { - bindingStyle: driver.bindingStyle, - connect: (params: Record) => driver.connect(params), + connect: async (params: Record) => { + middlewareOrder.push(prefix); + return driver.connect(params); + }, getDatabasePlatform: (versionProvider) => driver.getDatabasePlatform(versionProvider), getExceptionConverter: () => driver.getExceptionConverter(), - name: `${prefix}${driver.name}`, }; } } +const middlewareOrder: string[] = []; + describe("DriverManager", () => { it("lists available drivers", () => { expect(DriverManager.getAvailableDrivers().sort()).toEqual([ @@ -119,7 +123,7 @@ describe("DriverManager", () => { }); it("throws when no driver is configured", () => { - expect(() => DriverManager.getConnection({})).toThrow(DriverRequiredException); + expect(() => DriverManager.getConnection({})).toThrow(DriverRequired); }); it("throws for unknown driver name", () => { @@ -127,7 +131,7 @@ describe("DriverManager", () => { DriverManager.getConnection({ driver: "invalid" as unknown as "mysql2", }), - ).toThrow(UnknownDriverException); + ).toThrow(UnknownDriver); }); it("uses driverClass when provided", () => { @@ -135,7 +139,7 @@ describe("DriverManager", () => { driverClass: SpyDriver, }); - expect(connection.getDriver().name).toBe("spy"); + expect(connection.getDriver()).toBeInstanceOf(SpyDriver); }); it("prefers driverInstance over driverClass", () => { @@ -149,6 +153,7 @@ describe("DriverManager", () => { }); it("applies middlewares in declaration order", () => { + middlewareOrder.length = 0; const configuration = new Configuration(); configuration.addMiddleware(new PrefixMiddleware("a:")); configuration.addMiddleware(new PrefixMiddleware("b:")); @@ -160,6 +165,8 @@ describe("DriverManager", () => { configuration, ); - expect(connection.getDriver().name).toBe("b:a:spy"); + return connection.connect().then(() => { + expect(middlewareOrder).toEqual(["b:", "a:"]); + }); }); }); diff --git a/src/__tests__/driver/ibmdb2-exception-converter.test.ts b/src/__tests__/driver/ibmdb2-exception-converter.test.ts new file mode 100644 index 0000000..85f77cc --- /dev/null +++ b/src/__tests__/driver/ibmdb2-exception-converter.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { ExceptionConverter as IBMDB2ExceptionConverter } from "../../driver/api/ibmdb2/exception-converter"; +import { ConnectionException } from "../../exception/connection-exception"; +import { DriverException } from "../../exception/driver-exception"; +import { ForeignKeyConstraintViolationException } from "../../exception/foreign-key-constraint-violation-exception"; +import { InvalidFieldNameException } from "../../exception/invalid-field-name-exception"; +import { NonUniqueFieldNameException } from "../../exception/non-unique-field-name-exception"; +import { NotNullConstraintViolationException } from "../../exception/not-null-constraint-violation-exception"; +import { SyntaxErrorException } from "../../exception/syntax-error-exception"; +import { TableExistsException } from "../../exception/table-exists-exception"; +import { TableNotFoundException } from "../../exception/table-not-found-exception"; +import { UniqueConstraintViolationException } from "../../exception/unique-constraint-violation-exception"; +import { Query } from "../../query"; + +describe("IBMDB2 ExceptionConverter", () => { + it("maps DB2 SQLCODE values to supported DBAL exceptions", () => { + const converter = new IBMDB2ExceptionConverter(); + + expect( + converter.convert(Object.assign(new Error("SQL0104N syntax"), { sqlcode: -104 }), { + operation: "executeQuery", + }), + ).toBeInstanceOf(SyntaxErrorException); + + expect( + converter.convert(Object.assign(new Error("SQL0203N column ambiguous"), { sqlcode: -203 }), { + operation: "executeQuery", + }), + ).toBeInstanceOf(NonUniqueFieldNameException); + + expect( + converter.convert(Object.assign(new Error("SQL0204N table not found"), { sqlcode: -204 }), { + operation: "executeQuery", + }), + ).toBeInstanceOf(TableNotFoundException); + + expect( + converter.convert(Object.assign(new Error("SQL0206N column not valid"), { sqlcode: -206 }), { + operation: "executeQuery", + }), + ).toBeInstanceOf(InvalidFieldNameException); + + expect( + converter.convert(Object.assign(new Error("SQL0407N null not allowed"), { sqlcode: -407 }), { + operation: "executeStatement", + }), + ).toBeInstanceOf(NotNullConstraintViolationException); + + expect( + converter.convert(Object.assign(new Error("SQL0530N foreign key"), { sqlcode: -530 }), { + operation: "executeStatement", + }), + ).toBeInstanceOf(ForeignKeyConstraintViolationException); + + expect( + converter.convert(Object.assign(new Error("SQL0601N object exists"), { sqlcode: -601 }), { + operation: "executeStatement", + }), + ).toBeInstanceOf(TableExistsException); + + expect( + converter.convert(Object.assign(new Error("SQL0803N duplicate key"), { sqlcode: -803 }), { + operation: "executeStatement", + }), + ).toBeInstanceOf(UniqueConstraintViolationException); + + expect( + converter.convert( + Object.assign(new Error("SQL30082N connection failed"), { sqlcode: -30082 }), + { + operation: "connect", + }, + ), + ).toBeInstanceOf(ConnectionException); + }); + + it("captures query metadata and sqlstate", () => { + const converter = new IBMDB2ExceptionConverter(); + const query = new Query("SELECT * FROM users WHERE id = ?", [7]); + const error = Object.assign(new Error("SQL0204N USERS not found. SQLSTATE=42704"), { + sqlcode: "-204", + }); + + const converted = converter.convert(error, { operation: "executeQuery", query }); + + expect(converted).toBeInstanceOf(TableNotFoundException); + expect(converted.code).toBe(-204); + expect(converted.sqlState).toBe("42704"); + expect(converted.sql).toBe("SELECT * FROM users WHERE id = ?"); + expect(converted.parameters).toEqual([7]); + expect(converted.driverName).toBe("ibmdb2"); + }); + + it("falls back to DriverException for unknown codes", () => { + const converter = new IBMDB2ExceptionConverter(); + const error = Object.assign(new Error("Unknown DB2 driver failure"), { code: "SOMETHING" }); + + const converted = converter.convert(error, { operation: "executeQuery" }); + + expect(converted).toBeInstanceOf(DriverException); + expect(converted).not.toBeInstanceOf(ConnectionException); + expect(converted.code).toBe("SOMETHING"); + }); +}); diff --git a/src/__tests__/driver/middleware/abstract-connection-middleware.test.ts b/src/__tests__/driver/middleware/abstract-connection-middleware.test.ts new file mode 100644 index 0000000..2c5aacc --- /dev/null +++ b/src/__tests__/driver/middleware/abstract-connection-middleware.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { ArrayResult } from "../../../driver/array-result"; +import type { Connection as DriverConnection } from "../../../driver/connection"; +import { AbstractConnectionMiddleware } from "../../../driver/middleware/abstract-connection-middleware"; +import type { Statement as DriverStatement } from "../../../driver/statement"; + +class TestConnectionMiddleware extends AbstractConnectionMiddleware {} + +describe("AbstractConnectionMiddleware", () => { + it("delegates prepare()", async () => { + const statement = createStatementStub(); + const connection = createConnectionStub({ + prepare: async (sql) => { + expect(sql).toBe("SELECT 1"); + return statement; + }, + }); + + expect(await new TestConnectionMiddleware(connection).prepare("SELECT 1")).toBe(statement); + }); + + it("delegates query()", async () => { + const result = new ArrayResult([{ value: 1 }], ["value"], 1); + const connection = createConnectionStub({ + query: async (sql) => { + expect(sql).toBe("SELECT 1"); + return result; + }, + }); + + expect(await new TestConnectionMiddleware(connection).query("SELECT 1")).toBe(result); + }); + + it("delegates exec()", async () => { + const connection = createConnectionStub({ + exec: async (sql) => { + expect(sql).toBe("UPDATE foo SET bar='baz' WHERE some_field > 0"); + return 42; + }, + }); + + expect( + await new TestConnectionMiddleware(connection).exec( + "UPDATE foo SET bar='baz' WHERE some_field > 0", + ), + ).toBe(42); + }); + + it("delegates getServerVersion()", async () => { + const connection = createConnectionStub({ getServerVersion: async () => "1.2.3" }); + + await expect(new TestConnectionMiddleware(connection).getServerVersion()).resolves.toBe( + "1.2.3", + ); + }); + + it("delegates getNativeConnection()", () => { + const nativeConnection = { native: true }; + const connection = createConnectionStub({ getNativeConnection: () => nativeConnection }); + + expect(new TestConnectionMiddleware(connection).getNativeConnection()).toBe(nativeConnection); + }); +}); + +function createStatementStub(): DriverStatement { + return { + bindValue: () => undefined, + execute: async () => new ArrayResult([], [], 0), + }; +} + +function createConnectionStub(overrides: Partial): DriverConnection { + return { + beginTransaction: async () => undefined, + commit: async () => undefined, + exec: async () => 0, + getNativeConnection: () => ({}), + getServerVersion: async () => "0", + lastInsertId: async () => 0, + prepare: async () => createStatementStub(), + query: async () => new ArrayResult([], [], 0), + quote: (value: string) => value, + rollBack: async () => undefined, + ...overrides, + }; +} diff --git a/src/__tests__/driver/middleware/abstract-driver-middleware.test.ts b/src/__tests__/driver/middleware/abstract-driver-middleware.test.ts new file mode 100644 index 0000000..355aeb5 --- /dev/null +++ b/src/__tests__/driver/middleware/abstract-driver-middleware.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { StaticServerVersionProvider } from "../../../connection/static-server-version-provider"; +import type { Driver } from "../../../driver"; +import type { ExceptionConverter } from "../../../driver/api/exception-converter"; +import { AbstractDriverMiddleware } from "../../../driver/middleware/abstract-driver-middleware"; +import { DriverException } from "../../../exception/driver-exception"; +import { MySQLPlatform } from "../../../platforms/mysql-platform"; + +class TestDriverMiddleware extends AbstractDriverMiddleware {} + +class SpyExceptionConverter implements ExceptionConverter { + public convert(): DriverException { + return new DriverException("driver error", { driverName: "spy", operation: "connect" }); + } +} + +describe("AbstractDriverMiddleware", () => { + it("delegates connect()", async () => { + const connection = { connection: true }; + const driver: Driver = { + connect: async (params) => { + expect(params).toEqual({ host: "localhost" }); + return connection as never; + }, + getDatabasePlatform: () => new MySQLPlatform(), + getExceptionConverter: () => new SpyExceptionConverter(), + }; + + const middleware = new TestDriverMiddleware(driver); + + expect(await middleware.connect({ host: "localhost" })).toBe(connection); + }); + + it("delegates getExceptionConverter() and getDatabasePlatform()", () => { + const converter = new SpyExceptionConverter(); + const platform = new MySQLPlatform(); + const driver: Driver = { + connect: async () => ({}) as never, + getDatabasePlatform: () => platform, + getExceptionConverter: () => converter, + }; + + const middleware = new TestDriverMiddleware(driver); + + expect(middleware.getExceptionConverter()).toBe(converter); + expect(middleware.getDatabasePlatform(new StaticServerVersionProvider("8.0.0"))).toBe(platform); + }); +}); diff --git a/src/__tests__/driver/middleware/abstract-result-middleware.test.ts b/src/__tests__/driver/middleware/abstract-result-middleware.test.ts new file mode 100644 index 0000000..4547216 --- /dev/null +++ b/src/__tests__/driver/middleware/abstract-result-middleware.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { AbstractResultMiddleware } from "../../../driver/middleware/abstract-result-middleware"; +import type { Result as DriverResult } from "../../../driver/result"; + +class TestResultMiddleware extends AbstractResultMiddleware {} + +describe("AbstractResultMiddleware", () => { + it("delegates fetchAssociative()", () => { + const row = { another_field: 42, field: "value" }; + const result = createResultStub({ + fetchAssociative: () => row, + }); + + expect(new TestResultMiddleware(result).fetchAssociative()).toBe(row); + }); + + it("delegates getColumnName() when supported", () => { + const result = Object.assign(createResultStub(), { + getColumnName: (index: number) => `col_${index}`, + }); + + expect(new TestResultMiddleware(result).getColumnName(0)).toBe("col_0"); + }); + + it("throws when getColumnName() is unsupported", () => { + expect(() => new TestResultMiddleware(createResultStub()).getColumnName(0)).toThrow( + "does not support accessing the column name", + ); + }); +}); + +function createResultStub(overrides: Partial = {}): DriverResult { + return { + columnCount: () => 1, + fetchAllAssociative: () => [], + fetchAllNumeric: () => [], + fetchAssociative: () => false, + fetchFirstColumn: () => [], + fetchNumeric: () => false, + fetchOne: () => false, + free: () => undefined, + rowCount: () => 0, + ...overrides, + }; +} diff --git a/src/__tests__/driver/middleware/abstract-statement-middleware.test.ts b/src/__tests__/driver/middleware/abstract-statement-middleware.test.ts new file mode 100644 index 0000000..8856815 --- /dev/null +++ b/src/__tests__/driver/middleware/abstract-statement-middleware.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { ArrayResult } from "../../../driver/array-result"; +import { AbstractStatementMiddleware } from "../../../driver/middleware/abstract-statement-middleware"; +import type { Statement as DriverStatement } from "../../../driver/statement"; +import { ParameterType } from "../../../parameter-type"; + +class TestStatementMiddleware extends AbstractStatementMiddleware {} + +describe("AbstractStatementMiddleware", () => { + it("delegates execute()", async () => { + const result = new ArrayResult([], [], 0); + const statement: DriverStatement = { + bindValue: () => undefined, + execute: async () => result, + }; + + await expect(new TestStatementMiddleware(statement).execute()).resolves.toBe(result); + }); + + it("delegates bindValue()", () => { + const calls: unknown[] = []; + const statement: DriverStatement = { + bindValue: (param, value, type) => { + calls.push([param, value, type]); + }, + execute: async () => new ArrayResult([], [], 0), + }; + + new TestStatementMiddleware(statement).bindValue(1, "x", ParameterType.STRING); + expect(calls).toEqual([[1, "x", ParameterType.STRING]]); + }); +}); diff --git a/src/__tests__/driver/mssql-connection.test.ts b/src/__tests__/driver/mssql-connection.test.ts index 814a742..08a3bfd 100644 --- a/src/__tests__/driver/mssql-connection.test.ts +++ b/src/__tests__/driver/mssql-connection.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { MSSQLConnection } from "../../driver/mssql/connection"; -import { DbalException, InvalidParameterException } from "../../exception/index"; interface QueryPayload { recordset?: Array>; @@ -76,58 +75,35 @@ class FakePool { } describe("MSSQLConnection", () => { - it("executes named-parameter queries and normalizes row output", async () => { + it("executes named-parameter prepared queries and normalizes row output", async () => { const pool = new FakePool(async () => ({ recordset: [{ id: 1, name: "Alice" }], rowsAffected: [1], })); const connection = new MSSQLConnection(pool, false); + const statement = await connection.prepare( + "SELECT * FROM users WHERE id = @p1 AND status = @p2", + ); + statement.bindValue("id", 1); + statement.bindValue("status", "active"); - const result = await connection.executeQuery({ - parameters: { id: 1, status: "active" }, - sql: "SELECT * FROM users WHERE id = @p1 AND status = @p2", - types: { p1: "INTEGER", p2: "STRING" }, - }); + const result = await statement.execute(); expect(pool.requestInstance.inputs).toEqual([ { name: "id", value: 1 }, { name: "status", value: "active" }, ]); - expect(result).toEqual({ - columns: ["id", "name"], - rowCount: 1, - rows: [{ id: 1, name: "Alice" }], - }); + expect(result.fetchAllAssociative()).toEqual([{ id: 1, name: "Alice" }]); + expect(result.rowCount()).toBe(1); }); - it("normalizes statement affected rows", async () => { + it("normalizes exec() affected rows", async () => { const pool = new FakePool(async () => ({ rowsAffected: [2, 3], })); const connection = new MSSQLConnection(pool, false); - const result = await connection.executeStatement({ - parameters: { status: "active" }, - sql: "UPDATE users SET status = @status", - types: { status: "STRING" }, - }); - - expect(result).toEqual({ affectedRows: 5, insertId: null }); - }); - - it("rejects positional parameters after compilation", async () => { - const pool = new FakePool(async () => ({ - rowsAffected: [1], - })); - const connection = new MSSQLConnection(pool, false); - - await expect( - connection.executeQuery({ - parameters: [1], - sql: "SELECT * FROM users WHERE id = ?", - types: [], - }), - ).rejects.toThrow(InvalidParameterException); + expect(await connection.exec("UPDATE users SET status = 'active'")).toBe(5); }); it("serializes requests to respect single-flight behavior", async () => { @@ -147,8 +123,8 @@ describe("MSSQLConnection", () => { }); const connection = new MSSQLConnection(pool, false); - const first = connection.executeQuery({ parameters: {}, sql: "q1", types: {} }); - const second = connection.executeQuery({ parameters: {}, sql: "q2", types: {} }); + const first = connection.query("q1"); + const second = connection.query("q2"); await Promise.resolve(); expect(order).toEqual(["start:q1"]); @@ -171,11 +147,9 @@ describe("MSSQLConnection", () => { const connection = new MSSQLConnection(pool, false); await connection.beginTransaction(); - await connection.executeQuery({ - parameters: { id: 1 }, - sql: "SELECT @id AS v", - types: { id: "INTEGER" }, - }); + const statement = await connection.prepare("SELECT @id AS v"); + statement.bindValue("id", 1); + await statement.execute(); expect(pool.transactionInstance.beginCalls).toBe(1); expect(pool.transactionInstance.requestInstance.inputs).toEqual([{ name: "id", value: 1 }]); @@ -185,30 +159,17 @@ describe("MSSQLConnection", () => { expect(pool.transactionInstance.commitCalls).toBe(1); }); - it("supports rollback and savepoint SQL inside transactions", async () => { + it("supports rollback inside transactions", async () => { const pool = new FakePool(async () => ({ rowsAffected: [1], })); const connection = new MSSQLConnection(pool, false); await connection.beginTransaction(); - await connection.createSavepoint("sp1"); - await connection.rollbackSavepoint("sp1"); await connection.rollBack(); - - expect(pool.transactionInstance.requestInstance.queries).toContain("SAVE TRANSACTION sp1"); - expect(pool.transactionInstance.requestInstance.queries).toContain("ROLLBACK TRANSACTION sp1"); expect(pool.transactionInstance.rollbackCalls).toBe(1); }); - it("throws for savepoint usage outside transactions", async () => { - const pool = new FakePool(async () => ({ rowsAffected: [1] })); - const connection = new MSSQLConnection(pool, false); - - await expect(connection.createSavepoint("sp1")).rejects.toThrow(DbalException); - await expect(connection.rollbackSavepoint("sp1")).rejects.toThrow(DbalException); - }); - it("quotes values and reads server version", async () => { const pool = new FakePool(async (sql: string) => { if (sql.includes("@@VERSION")) { diff --git a/src/__tests__/driver/mssql-driver.test.ts b/src/__tests__/driver/mssql-driver.test.ts index 10f8cc8..c47fcf0 100644 --- a/src/__tests__/driver/mssql-driver.test.ts +++ b/src/__tests__/driver/mssql-driver.test.ts @@ -1,21 +1,13 @@ import { describe, expect, it } from "vitest"; -import { ParameterBindingStyle } from "../../driver"; +import { ExceptionConverter as SQLServerExceptionConverter } from "../../driver/api/sql-server/exception-converter"; import { MSSQLDriver } from "../../driver/mssql/driver"; -import { DbalException } from "../../exception/index"; describe("MSSQLDriver", () => { - it("exposes expected metadata", () => { - const driver = new MSSQLDriver(); - - expect(driver.name).toBe("mssql"); - expect(driver.bindingStyle).toBe(ParameterBindingStyle.NAMED); - }); - it("throws when no client object is provided", async () => { const driver = new MSSQLDriver(); - await expect(driver.connect({})).rejects.toThrow(DbalException); + await expect(driver.connect({})).rejects.toThrow(Error); }); it("prefers pool over connection/client in params", async () => { @@ -96,9 +88,9 @@ describe("MSSQLDriver", () => { expect(calls.close).toBe(1); }); - it("returns a stable exception converter instance", () => { + it("returns the Doctrine SQL Server exception converter", () => { const driver = new MSSQLDriver(); - expect(driver.getExceptionConverter()).toBe(driver.getExceptionConverter()); + expect(driver.getExceptionConverter()).toBeInstanceOf(SQLServerExceptionConverter); }); }); diff --git a/src/__tests__/driver/mysql2-connection.test.ts b/src/__tests__/driver/mysql2-connection.test.ts index 9c4bd5d..9e0bd92 100644 --- a/src/__tests__/driver/mysql2-connection.test.ts +++ b/src/__tests__/driver/mysql2-connection.test.ts @@ -1,26 +1,20 @@ import { describe, expect, it } from "vitest"; import { MySQL2Connection } from "../../driver/mysql2/connection"; -import { DbalException, InvalidParameterException } from "../../exception/index"; +import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; describe("MySQL2Connection", () => { - it("executes query using execute() and normalizes rows", async () => { + it("executes prepared query using execute() and normalizes rows", async () => { const client = { execute: async () => [[{ id: 1, name: "Alice" }], []], }; const connection = new MySQL2Connection(client, false); + const statement = await connection.prepare("SELECT id, name FROM users WHERE id = ?"); + statement.bindValue(1, 1); - const result = await connection.executeQuery({ - parameters: [1], - sql: "SELECT id, name FROM users WHERE id = ?", - types: [], - }); - - expect(result).toEqual({ - columns: ["id", "name"], - rowCount: 1, - rows: [{ id: 1, name: "Alice" }], - }); + const result = await statement.execute(); + expect(result.fetchAllAssociative()).toEqual([{ id: 1, name: "Alice" }]); + expect(result.rowCount()).toBe(1); }); it("falls back to query() when execute() is unavailable", async () => { @@ -29,70 +23,46 @@ describe("MySQL2Connection", () => { }; const connection = new MySQL2Connection(client, false); - const result = await connection.executeQuery({ - parameters: [], - sql: "SELECT 1 AS value", - types: [], - }); - - expect(result.rows).toEqual([{ value: 1 }]); + const result = await connection.query("SELECT 1 AS value"); + expect(result.fetchAllAssociative()).toEqual([{ value: 1 }]); }); - it("normalizes statement metadata", async () => { + it("normalizes statement metadata through exec() and lastInsertId()", async () => { const client = { execute: async () => [{ affectedRows: 3, insertId: 99 }], }; const connection = new MySQL2Connection(client, false); - const result = await connection.executeStatement({ - parameters: [], - sql: "UPDATE users SET active = 1", - types: [], - }); - - expect(result).toEqual({ affectedRows: 3, insertId: 99 }); + expect(await connection.exec("UPDATE users SET active = 1")).toBe(3); + await expect(connection.lastInsertId()).resolves.toBe(99); }); - it("derives affected rows from array results when metadata is missing", async () => { + it("returns rows from prepared statements and rowCount", async () => { const client = { query: async () => [[{ id: 1 }, { id: 2 }], []], }; const connection = new MySQL2Connection(client, false); + const statement = await connection.prepare("SELECT id FROM users"); + const result = await statement.execute(); - const result = await connection.executeStatement({ - parameters: [], - sql: "SELECT id FROM users", - types: [], - }); - - expect(result).toEqual({ affectedRows: 2, insertId: null }); + expect(result.fetchAllAssociative()).toEqual([{ id: 1 }, { id: 2 }]); + expect(result.rowCount()).toBe(2); }); - it("rejects named parameter payloads after compilation", async () => { + it("rejects named parameter binding", async () => { const client = { query: async () => [[{ id: 1 }], []], }; const connection = new MySQL2Connection(client, false); + const statement = await connection.prepare("SELECT id FROM users WHERE id = ?"); - await expect( - connection.executeQuery({ - parameters: { id: 1 }, - sql: "SELECT id FROM users WHERE id = :id", - types: {}, - }), - ).rejects.toThrow(InvalidParameterException); + expect(() => statement.bindValue("id", 1)).toThrow(InvalidParameterException); }); it("throws when client has neither execute() nor query()", async () => { const connection = new MySQL2Connection({}, false); - await expect( - connection.executeQuery({ - parameters: [], - sql: "SELECT 1", - types: [], - }), - ).rejects.toThrow(DbalException); + await expect(connection.query("SELECT 1")).rejects.toThrow(Error); }); it("begins and commits transactions on acquired pooled connections", async () => { @@ -164,31 +134,8 @@ describe("MySQL2Connection", () => { it("throws for invalid transaction transitions", async () => { const connection = new MySQL2Connection({ query: async () => [] }, false); - await expect(connection.commit()).rejects.toThrow(DbalException); - await expect(connection.rollBack()).rejects.toThrow(DbalException); - }); - - it("issues savepoint SQL", async () => { - const capturedSql: string[] = []; - const connection = new MySQL2Connection( - { - query: async (sql: string) => { - capturedSql.push(sql); - return []; - }, - }, - false, - ); - - await connection.createSavepoint("sp1"); - await connection.releaseSavepoint("sp1"); - await connection.rollbackSavepoint("sp1"); - - expect(capturedSql).toEqual([ - "SAVEPOINT sp1", - "RELEASE SAVEPOINT sp1", - "ROLLBACK TO SAVEPOINT sp1", - ]); + await expect(connection.commit()).rejects.toThrow(Error); + await expect(connection.rollBack()).rejects.toThrow(Error); }); it("quotes string values and reads server version", async () => { diff --git a/src/__tests__/driver/mysql2-driver.test.ts b/src/__tests__/driver/mysql2-driver.test.ts index 6fe5eba..1bfb3a7 100644 --- a/src/__tests__/driver/mysql2-driver.test.ts +++ b/src/__tests__/driver/mysql2-driver.test.ts @@ -1,29 +1,13 @@ import { describe, expect, it } from "vitest"; -import { ParameterBindingStyle } from "../../driver"; +import { ExceptionConverter as MySQLExceptionConverter } from "../../driver/api/mysql/exception-converter"; import { MySQL2Driver } from "../../driver/mysql2/driver"; -import { DbalException } from "../../exception/index"; -import { InvalidPlatformVersion } from "../../platforms/exception/invalid-platform-version"; -import { MariaDB1010Platform } from "../../platforms/mariadb1010-platform"; -import { MariaDB1052Platform } from "../../platforms/mariadb1052-platform"; -import { MariaDB110700Platform } from "../../platforms/mariadb110700-platform"; -import { MySQLPlatform } from "../../platforms/mysql-platform"; -import { MySQL80Platform } from "../../platforms/mysql80-platform"; -import { MySQL84Platform } from "../../platforms/mysql84-platform"; -import { StaticServerVersionProvider } from "../../static-server-version-provider"; describe("MySQL2Driver", () => { - it("exposes expected metadata", () => { - const driver = new MySQL2Driver(); - - expect(driver.name).toBe("mysql2"); - expect(driver.bindingStyle).toBe(ParameterBindingStyle.POSITIONAL); - }); - it("throws when no client object is provided", async () => { const driver = new MySQL2Driver(); - await expect(driver.connect({})).rejects.toThrow(DbalException); + await expect(driver.connect({})).rejects.toThrow(Error); }); it("prefers pool over connection/client in params", async () => { @@ -89,53 +73,9 @@ describe("MySQL2Driver", () => { expect(calls.end).toBe(0); }); - it("returns a stable exception converter instance", () => { - const driver = new MySQL2Driver(); - - expect(driver.getExceptionConverter()).toBe(driver.getExceptionConverter()); - }); - - it("resolves MySQL platform variants from static versions", () => { - const driver = new MySQL2Driver(); - - expect(driver.getDatabasePlatform(new StaticServerVersionProvider("8.0.36"))).toBeInstanceOf( - MySQL80Platform, - ); - expect(driver.getDatabasePlatform(new StaticServerVersionProvider("8.4.2"))).toBeInstanceOf( - MySQL84Platform, - ); - expect(driver.getDatabasePlatform(new StaticServerVersionProvider("5.7.44"))).toBeInstanceOf( - MySQLPlatform, - ); - }); - - it("resolves MariaDB platform variants from static versions", () => { - const driver = new MySQL2Driver(); - - expect( - driver.getDatabasePlatform(new StaticServerVersionProvider("10.10.2-MariaDB-1:10.10.2")), - ).toBeInstanceOf(MariaDB1010Platform); - expect( - driver.getDatabasePlatform(new StaticServerVersionProvider("11.7.1-MariaDB-ubu2404")), - ).toBeInstanceOf(MariaDB110700Platform); - expect( - driver.getDatabasePlatform(new StaticServerVersionProvider("5.5.5-MariaDB-10.5.21")), - ).toBeInstanceOf(MariaDB1052Platform); - }); - - it("throws InvalidPlatformVersion for malformed MariaDB versions", () => { - const driver = new MySQL2Driver(); - - expect(() => - driver.getDatabasePlatform(new StaticServerVersionProvider("mariadb-not-a-version")), - ).toThrow(InvalidPlatformVersion); - }); - - it("throws InvalidPlatformVersion for malformed MySQL versions", () => { + it("returns the Doctrine MySQL exception converter", () => { const driver = new MySQL2Driver(); - expect(() => - driver.getDatabasePlatform(new StaticServerVersionProvider("totally-invalid-version")), - ).toThrow(InvalidPlatformVersion); + expect(driver.getExceptionConverter()).toBeInstanceOf(MySQLExceptionConverter); }); }); diff --git a/src/__tests__/driver/oci-exception-converter.test.ts b/src/__tests__/driver/oci-exception-converter.test.ts new file mode 100644 index 0000000..eedf7e5 --- /dev/null +++ b/src/__tests__/driver/oci-exception-converter.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import { ExceptionConverter as OCIExceptionConverter } from "../../driver/api/oci/exception-converter"; +import { ConnectionException } from "../../exception/connection-exception"; +import { DatabaseDoesNotExist } from "../../exception/database-does-not-exist"; +import { DatabaseObjectNotFoundException } from "../../exception/database-object-not-found-exception"; +import { ForeignKeyConstraintViolationException } from "../../exception/foreign-key-constraint-violation-exception"; +import { InvalidFieldNameException } from "../../exception/invalid-field-name-exception"; +import { NonUniqueFieldNameException } from "../../exception/non-unique-field-name-exception"; +import { NotNullConstraintViolationException } from "../../exception/not-null-constraint-violation-exception"; +import { SyntaxErrorException } from "../../exception/syntax-error-exception"; +import { TableExistsException } from "../../exception/table-exists-exception"; +import { TableNotFoundException } from "../../exception/table-not-found-exception"; +import { UniqueConstraintViolationException } from "../../exception/unique-constraint-violation-exception"; +import { Query } from "../../query"; + +describe("OCI ExceptionConverter", () => { + it("maps Oracle codes to DBAL exceptions", () => { + const converter = new OCIExceptionConverter(); + + expect( + converter.convert( + Object.assign(new Error("ORA-00001: unique constraint violated"), { code: 1 }), + { + operation: "executeStatement", + }, + ), + ).toBeInstanceOf(UniqueConstraintViolationException); + + expect( + converter.convert(Object.assign(new Error("ORA-00904: invalid identifier"), { code: 904 }), { + operation: "executeQuery", + }), + ).toBeInstanceOf(InvalidFieldNameException); + + expect( + converter.convert( + Object.assign(new Error("ORA-00918: column ambiguously defined"), { errorNum: 918 }), + { + operation: "executeQuery", + }, + ), + ).toBeInstanceOf(NonUniqueFieldNameException); + + expect( + converter.convert( + Object.assign(new Error("ORA-00923: FROM keyword not found"), { errorNum: 923 }), + { + operation: "executeQuery", + }, + ), + ).toBeInstanceOf(SyntaxErrorException); + + expect( + converter.convert( + Object.assign(new Error("ORA-00942: table or view does not exist"), { code: 942 }), + { + operation: "executeQuery", + }, + ), + ).toBeInstanceOf(TableNotFoundException); + + expect( + converter.convert( + Object.assign(new Error("ORA-00955: name is already used"), { errorNum: 955 }), + { + operation: "executeStatement", + }, + ), + ).toBeInstanceOf(TableExistsException); + + expect( + converter.convert( + Object.assign(new Error("ORA-01400: cannot insert NULL"), { code: "ORA-01400" }), + { + operation: "executeStatement", + }, + ), + ).toBeInstanceOf(NotNullConstraintViolationException); + + expect( + converter.convert( + Object.assign(new Error("ORA-01918: user does not exist"), { errorNum: 1918 }), + { + operation: "connect", + }, + ), + ).toBeInstanceOf(DatabaseDoesNotExist); + + expect( + converter.convert( + Object.assign(new Error("ORA-02291: integrity constraint violated"), { errorNum: 2291 }), + { + operation: "executeStatement", + }, + ), + ).toBeInstanceOf(ForeignKeyConstraintViolationException); + + expect( + converter.convert( + Object.assign(new Error("ORA-02289: sequence does not exist"), { errorNum: 2289 }), + { + operation: "executeQuery", + }, + ), + ).toBeInstanceOf(DatabaseObjectNotFoundException); + + expect( + converter.convert( + Object.assign(new Error("ORA-01017: invalid username/password"), { errorNum: 1017 }), + { + operation: "connect", + }, + ), + ).toBeInstanceOf(ConnectionException); + }); + + it("captures query metadata and SQLSTATE for mapped Oracle errors", () => { + const converter = new OCIExceptionConverter(); + const query = new Query("SELECT missing_col FROM users", []); + const error = Object.assign(new Error("SQLSTATE[HY000]: ORA-00904: invalid identifier"), { + code: "ORA-00904", + }); + + const converted = converter.convert(error, { operation: "executeQuery", query }); + + expect(converted).toBeInstanceOf(InvalidFieldNameException); + expect(converted.code).toBe(904); + expect(converted.sqlState).toBe("HY000"); + expect(converted.sql).toBe("SELECT missing_col FROM users"); + expect(converted.driverName).toBe("oci8"); + }); + + it("unwraps ORA-02091 and converts the nested Oracle error", () => { + const converter = new OCIExceptionConverter(); + const error = new Error( + "ORA-02091: transaction rolled back\nORA-00001: unique constraint (APP.USERS_PK) violated", + ); + const query = new Query("COMMIT"); + + const converted = converter.convert(Object.assign(error, { errorNum: 2091 }), { + operation: "commit", + query, + }); + + expect(converted).toBeInstanceOf(UniqueConstraintViolationException); + expect(converted.code).toBe(1); + expect(converted.operation).toBe("commit"); + expect(converted.sql).toBe("COMMIT"); + }); +}); diff --git a/src/__tests__/driver/pg-connection.test.ts b/src/__tests__/driver/pg-connection.test.ts index e42434b..bbdb841 100644 --- a/src/__tests__/driver/pg-connection.test.ts +++ b/src/__tests__/driver/pg-connection.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { PgConnection } from "../../driver/pg/connection"; import type { PgQueryResultLike } from "../../driver/pg/types"; -import { DbalException, InvalidParameterException } from "../../exception/index"; +import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; interface LoggedCall { parameters?: unknown[]; @@ -51,7 +51,7 @@ class FakePgPool { } describe("PgConnection", () => { - it("rewrites positional placeholders and normalizes query results", async () => { + it("rewrites positional placeholders in prepared statements and normalizes query results", async () => { const connection = new PgConnection( new FakePgClient(async (_sql, _params) => ({ fields: [{ name: "id" }, { name: "name" }], @@ -60,18 +60,15 @@ describe("PgConnection", () => { })), false, ); + const statement = await connection.prepare( + "SELECT id, name FROM users WHERE id = ? AND status = ?", + ); + statement.bindValue(1, 1); + statement.bindValue(2, "active"); - const result = await connection.executeQuery({ - parameters: [1, "active"], - sql: "SELECT id, name FROM users WHERE id = ? AND status = ?", - types: [], - }); - - expect(result).toEqual({ - columns: ["id", "name"], - rowCount: 1, - rows: [{ id: 1, name: "Alice" }], - }); + const result = await statement.execute(); + expect(result.fetchAllAssociative()).toEqual([{ id: 1, name: "Alice" }]); + expect(result.rowCount()).toBe(1); const native = connection.getNativeConnection() as FakePgClient; expect(native.calls[0]).toEqual({ parameters: [1, "active"], @@ -79,16 +76,11 @@ describe("PgConnection", () => { }); }); - it("rejects named parameter payloads after compilation", async () => { + it("rejects named parameter binding", async () => { const connection = new PgConnection(new FakePgClient(async () => ({ rows: [] })), false); + const statement = await connection.prepare("SELECT * FROM users WHERE id = ?"); - await expect( - connection.executeQuery({ - parameters: { id: 1 }, - sql: "SELECT * FROM users WHERE id = :id", - types: {}, - }), - ).rejects.toThrow(InvalidParameterException); + expect(() => statement.bindValue("id", 1)).toThrow(InvalidParameterException); }); it("uses a dedicated pooled client for transactions and releases it", async () => { @@ -97,11 +89,10 @@ describe("PgConnection", () => { const connection = new PgConnection(pool, false); await connection.beginTransaction(); - await connection.executeStatement({ - parameters: [1], - sql: "UPDATE users SET active = ?", - types: [], - }); + const statement = await connection.prepare("UPDATE users SET active = ?"); + statement.bindValue(1, 1); + const result = await statement.execute(); + expect(result.rowCount()).toBe(0); await connection.commit(); expect(txClient.calls.map((call) => call.sql)).toEqual([ @@ -112,7 +103,7 @@ describe("PgConnection", () => { expect(txClient.released).toBe(1); }); - it("supports savepoints, quoting and server version lookup", async () => { + it("supports query(), exec(), quoting and server version lookup", async () => { const client = new FakePgClient(async (sql) => { if (sql === "SHOW server_version") { return { rows: [{ server_version: "16.2" }] }; @@ -122,14 +113,12 @@ describe("PgConnection", () => { }); const connection = new PgConnection(client, false); - await connection.createSavepoint("sp1"); - await connection.releaseSavepoint("sp1"); - await connection.rollbackSavepoint("sp1"); + await connection.query("SELECT 1"); + await connection.exec("UPDATE users SET active = TRUE"); - expect(client.calls.slice(0, 3).map((call) => call.sql)).toEqual([ - "SAVEPOINT sp1", - "RELEASE SAVEPOINT sp1", - "ROLLBACK TO SAVEPOINT sp1", + expect(client.calls.slice(0, 2).map((call) => call.sql)).toEqual([ + "SELECT 1", + "UPDATE users SET active = TRUE", ]); expect(connection.quote("O'Reilly")).toBe("'O''Reilly'"); await expect(connection.getServerVersion()).resolves.toBe("16.2"); @@ -141,7 +130,7 @@ describe("PgConnection", () => { false, ); - await expect(connection.commit()).rejects.toThrow(DbalException); - await expect(connection.rollBack()).rejects.toThrow(DbalException); + await expect(connection.commit()).rejects.toThrow(Error); + await expect(connection.rollBack()).rejects.toThrow(Error); }); }); diff --git a/src/__tests__/driver/pg-driver.test.ts b/src/__tests__/driver/pg-driver.test.ts index 304557a..18c9fa2 100644 --- a/src/__tests__/driver/pg-driver.test.ts +++ b/src/__tests__/driver/pg-driver.test.ts @@ -1,25 +1,13 @@ import { describe, expect, it } from "vitest"; -import { ParameterBindingStyle } from "../../driver"; +import { ExceptionConverter as PgSQLExceptionConverter } from "../../driver/api/pgsql/exception-converter"; import { PgDriver } from "../../driver/pg/driver"; -import { DbalException } from "../../exception/index"; -import { InvalidPlatformVersion } from "../../platforms/exception/invalid-platform-version"; -import { PostgreSQLPlatform } from "../../platforms/postgre-sql-platform"; -import { PostgreSQL120Platform } from "../../platforms/postgre-sql120-platform"; -import { StaticServerVersionProvider } from "../../static-server-version-provider"; describe("PgDriver", () => { - it("exposes expected metadata", () => { - const driver = new PgDriver(); - - expect(driver.name).toBe("pg"); - expect(driver.bindingStyle).toBe(ParameterBindingStyle.POSITIONAL); - }); - it("throws when no client object is provided", async () => { const driver = new PgDriver(); - await expect(driver.connect({})).rejects.toThrow(DbalException); + await expect(driver.connect({})).rejects.toThrow(Error); }); it("prefers pool over connection/client in params", async () => { @@ -55,25 +43,8 @@ describe("PgDriver", () => { expect(calls.end).toBe(1); }); - it("returns a stable exception converter instance", () => { - const driver = new PgDriver(); - expect(driver.getExceptionConverter()).toBe(driver.getExceptionConverter()); - }); - - it("returns PostgreSQL platform variants from server version", () => { - const driver = new PgDriver(); - const platform = driver.getDatabasePlatform(new StaticServerVersionProvider("11.22")); - const platform120 = driver.getDatabasePlatform(new StaticServerVersionProvider("16.2")); - - expect(platform).toBeInstanceOf(PostgreSQLPlatform); - expect(platform120).toBeInstanceOf(PostgreSQL120Platform); - }); - - it("throws InvalidPlatformVersion for malformed PostgreSQL versions", () => { + it("returns the Doctrine PostgreSQL exception converter", () => { const driver = new PgDriver(); - - expect(() => - driver.getDatabasePlatform(new StaticServerVersionProvider("not-a-postgres-version")), - ).toThrow(InvalidPlatformVersion); + expect(driver.getExceptionConverter()).toBeInstanceOf(PgSQLExceptionConverter); }); }); diff --git a/src/__tests__/driver/sqlite3-connection.test.ts b/src/__tests__/driver/sqlite3-connection.test.ts index 3f43ecf..47238cc 100644 --- a/src/__tests__/driver/sqlite3-connection.test.ts +++ b/src/__tests__/driver/sqlite3-connection.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { SQLite3Connection } from "../../driver/sqlite3/connection"; -import { DbalException, InvalidParameterException } from "../../exception/index"; +import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; class FakeSQLiteDatabase { public readonly allCalls: Array<{ parameters: unknown[]; sql: string }> = []; @@ -48,68 +48,47 @@ class FakeSQLiteDatabase { } describe("SQLite3Connection", () => { - it("executes queries and normalizes rows", async () => { + it("executes prepared queries and normalizes rows", async () => { const db = new FakeSQLiteDatabase(() => [{ id: 1, name: "Alice" }]); const connection = new SQLite3Connection(db, false); + const statement = await connection.prepare("SELECT id, name FROM users WHERE id = ?"); - const result = await connection.executeQuery({ - parameters: [1], - sql: "SELECT id, name FROM users WHERE id = ?", - types: [], - }); - - expect(result).toEqual({ - columns: ["id", "name"], - rowCount: 1, - rows: [{ id: 1, name: "Alice" }], - }); + statement.bindValue(1, 1); + const result = await statement.execute(); + expect(result.fetchAllAssociative()).toEqual([{ id: 1, name: "Alice" }]); + expect(result.rowCount()).toBe(1); }); - it("executes statements and returns changes/insertId", async () => { + it("executes statements and exposes affected rows/lastInsertId", async () => { const db = new FakeSQLiteDatabase( () => [], () => ({ changes: 2, lastID: 7 }), ); const connection = new SQLite3Connection(db, false); - const result = await connection.executeStatement({ - parameters: ["active"], - sql: "UPDATE users SET status = ?", - types: [], - }); + const statement = await connection.prepare("UPDATE users SET status = ?"); + statement.bindValue(1, "active"); + const result = await statement.execute(); - expect(result).toEqual({ affectedRows: 2, insertId: 7 }); + expect(result.rowCount()).toBe(2); + await expect(connection.lastInsertId()).resolves.toBe(7); }); - it("rejects named parameter payloads after compilation", async () => { + it("rejects named parameter binding", async () => { const connection = new SQLite3Connection(new FakeSQLiteDatabase(), false); + const statement = await connection.prepare("SELECT * FROM users WHERE id = ?"); - await expect( - connection.executeQuery({ - parameters: { id: 1 }, - sql: "SELECT * FROM users WHERE id = :id", - types: {}, - }), - ).rejects.toThrow(InvalidParameterException); + expect(() => statement.bindValue("id", 1)).toThrow(InvalidParameterException); }); - it("supports transactions and savepoints via exec()", async () => { + it("supports transactions via exec()", async () => { const db = new FakeSQLiteDatabase(); const connection = new SQLite3Connection(db, false); await connection.beginTransaction(); - await connection.createSavepoint("sp1"); - await connection.releaseSavepoint("sp1"); - await connection.rollbackSavepoint("sp1"); await connection.commit(); - expect(db.execCalls).toEqual([ - "BEGIN", - "SAVEPOINT sp1", - "RELEASE SAVEPOINT sp1", - "ROLLBACK TO SAVEPOINT sp1", - "COMMIT", - ]); + expect(db.execCalls).toEqual(["BEGIN", "COMMIT"]); }); it("quotes values and reads sqlite version", async () => { @@ -123,6 +102,7 @@ describe("SQLite3Connection", () => { const connection = new SQLite3Connection(db, false); expect(connection.quote("O'Reilly")).toBe("'O''Reilly'"); + await expect(connection.query("SELECT 1")).resolves.toBeDefined(); await expect(connection.getServerVersion()).resolves.toBe("3.45"); }); @@ -130,8 +110,8 @@ describe("SQLite3Connection", () => { const db = new FakeSQLiteDatabase(); const connection = new SQLite3Connection(db, true); - await expect(connection.commit()).rejects.toThrow(DbalException); - await expect(connection.rollBack()).rejects.toThrow(DbalException); + await expect(connection.commit()).rejects.toThrow(Error); + await expect(connection.rollBack()).rejects.toThrow(Error); await connection.close(); expect(db.closeCalls).toBe(1); diff --git a/src/__tests__/driver/sqlite3-driver.test.ts b/src/__tests__/driver/sqlite3-driver.test.ts index a330558..f1cca8f 100644 --- a/src/__tests__/driver/sqlite3-driver.test.ts +++ b/src/__tests__/driver/sqlite3-driver.test.ts @@ -1,23 +1,15 @@ import { describe, expect, it } from "vitest"; -import { ParameterBindingStyle } from "../../driver"; +import { StaticServerVersionProvider } from "../../connection/static-server-version-provider"; +import { ExceptionConverter as SQLiteExceptionConverter } from "../../driver/api/sqlite/exception-converter"; import { SQLite3Driver } from "../../driver/sqlite3/driver"; -import { DbalException } from "../../exception/index"; import { SQLitePlatform } from "../../platforms/sqlite-platform"; -import { StaticServerVersionProvider } from "../../static-server-version-provider"; describe("SQLite3Driver", () => { - it("exposes expected metadata", () => { - const driver = new SQLite3Driver(); - - expect(driver.name).toBe("sqlite3"); - expect(driver.bindingStyle).toBe(ParameterBindingStyle.POSITIONAL); - }); - it("throws when no database object is provided", async () => { const driver = new SQLite3Driver(); - await expect(driver.connect({})).rejects.toThrow(DbalException); + await expect(driver.connect({})).rejects.toThrow(Error); }); it("prefers database over connection/client", async () => { @@ -56,9 +48,9 @@ describe("SQLite3Driver", () => { expect(calls.close).toBe(1); }); - it("returns a stable exception converter instance", () => { + it("returns the Doctrine SQLite exception converter", () => { const driver = new SQLite3Driver(); - expect(driver.getExceptionConverter()).toBe(driver.getExceptionConverter()); + expect(driver.getExceptionConverter()).toBeInstanceOf(SQLiteExceptionConverter); }); it("returns the SQLite platform", () => { diff --git a/src/__tests__/driver/version-aware-platform-driver.test.ts b/src/__tests__/driver/version-aware-platform-driver.test.ts new file mode 100644 index 0000000..f8545b8 --- /dev/null +++ b/src/__tests__/driver/version-aware-platform-driver.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { StaticServerVersionProvider } from "../../connection/static-server-version-provider"; +import type { Driver } from "../../driver"; +import { MySQL2Driver } from "../../driver/mysql2/driver"; +import { PgDriver } from "../../driver/pg/driver"; +import type { AbstractPlatform } from "../../platforms/abstract-platform"; +import { InvalidPlatformVersion } from "../../platforms/exception/invalid-platform-version"; +import { MariaDBPlatform } from "../../platforms/mariadb-platform"; +import { MariaDB1010Platform } from "../../platforms/mariadb1010-platform"; +import { MariaDB1052Platform } from "../../platforms/mariadb1052-platform"; +import { MariaDB1060Platform } from "../../platforms/mariadb1060-platform"; +import { MariaDB110700Platform } from "../../platforms/mariadb110700-platform"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; +import { MySQL80Platform } from "../../platforms/mysql80-platform"; +import { MySQL84Platform } from "../../platforms/mysql84-platform"; +import { PostgreSQLPlatform } from "../../platforms/postgre-sql-platform"; +import { PostgreSQL120Platform } from "../../platforms/postgre-sql120-platform"; + +describe("VersionAwarePlatformDriver", () => { + it.each( + mySqlVersionProvider(), + )("MySQL2Driver instantiates %p for version %p", (version, expectedClass) => { + assertDriverInstantiatesDatabasePlatform(new MySQL2Driver(), version, expectedClass); + }); + + it.each( + postgreSqlVersionProvider(), + )("PgDriver instantiates %p for version %p", (version, expectedClass) => { + assertDriverInstantiatesDatabasePlatform(new PgDriver(), version, expectedClass); + }); + + it("throws on malformed MySQL/MariaDB versions", () => { + expect(() => + new MySQL2Driver().getDatabasePlatform( + new StaticServerVersionProvider("mariadb-not-a-version"), + ), + ).toThrow(InvalidPlatformVersion); + + expect(() => + new MySQL2Driver().getDatabasePlatform( + new StaticServerVersionProvider("totally-invalid-version"), + ), + ).toThrow(InvalidPlatformVersion); + }); + + it("throws on malformed PostgreSQL versions", () => { + expect(() => + new PgDriver().getDatabasePlatform(new StaticServerVersionProvider("not-a-postgres-version")), + ).toThrow(InvalidPlatformVersion); + }); +}); + +function assertDriverInstantiatesDatabasePlatform( + driver: Driver, + version: string, + expectedClass: new (...args: never[]) => AbstractPlatform, +): void { + const platform = driver.getDatabasePlatform(new StaticServerVersionProvider(version)); + + expect(platform).toBeInstanceOf(expectedClass); +} + +function mySqlVersionProvider(): Array<[string, new (...args: never[]) => AbstractPlatform]> { + return [ + ["5.7.0", MySQLPlatform], + ["8.0.11", MySQL80Platform], + ["8.4.1", MySQL84Platform], + ["9.0.0", MySQL84Platform], + ["5.5.40-MariaDB-1~wheezy", MariaDBPlatform], + ["5.5.5-MariaDB-10.2.8+maria~xenial-log", MariaDBPlatform], + ["10.2.8-MariaDB-10.2.8+maria~xenial-log", MariaDBPlatform], + ["10.2.8-MariaDB-1~lenny-log", MariaDBPlatform], + ["10.5.2-MariaDB-1~lenny-log", MariaDB1052Platform], + ["10.6.0-MariaDB-1~lenny-log", MariaDB1060Platform], + ["10.9.3-MariaDB-1~lenny-log", MariaDB1060Platform], + ["11.0.2-MariaDB-1:11.0.2+maria~ubu2204", MariaDB1010Platform], + ["11.7.1-MariaDB-ubu2404", MariaDB110700Platform], + ]; +} + +function postgreSqlVersionProvider(): Array<[string, new (...args: never[]) => AbstractPlatform]> { + return [ + ["10.0", PostgreSQLPlatform], + ["11.0", PostgreSQLPlatform], + ["12.0", PostgreSQL120Platform], + ["13.16", PostgreSQL120Platform], + ["16.4", PostgreSQL120Platform], + ]; +} diff --git a/src/__tests__/exception/doctrine-exception-shims.test.ts b/src/__tests__/exception/doctrine-exception-shims.test.ts new file mode 100644 index 0000000..dd0b7b6 --- /dev/null +++ b/src/__tests__/exception/doctrine-exception-shims.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { CommitFailedRollbackOnly } from "../../exception/commit-failed-rollback-only"; +import { ConnectionException } from "../../exception/connection-exception"; +import { ConnectionLost } from "../../exception/connection-lost"; +import { DatabaseRequired } from "../../exception/database-required"; +import { DriverException } from "../../exception/driver-exception"; +import { LockWaitTimeoutException } from "../../exception/lock-wait-timeout-exception"; +import { NoActiveTransaction } from "../../exception/no-active-transaction"; +import { SavepointsNotSupported } from "../../exception/savepoints-not-supported"; +import { ServerException } from "../../exception/server-exception"; +import { SyntaxErrorException } from "../../exception/syntax-error-exception"; +import { TransactionRolledBack } from "../../exception/transaction-rolled-back"; + +describe("Doctrine exception shims", () => { + it("provides doctrine-style connection exception factories", () => { + expect(NoActiveTransaction.new()).toBeInstanceOf(ConnectionException); + expect(NoActiveTransaction.new().message).toBe("There is no active transaction."); + + expect(CommitFailedRollbackOnly.new()).toBeInstanceOf(ConnectionException); + expect(CommitFailedRollbackOnly.new().message).toBe( + "Transaction commit failed because the transaction has been marked for rollback only.", + ); + + expect(SavepointsNotSupported.new()).toBeInstanceOf(ConnectionException); + expect(SavepointsNotSupported.new().message).toBe( + "Savepoints are not supported by this driver.", + ); + }); + + it("provides doctrine-style server exception aliases", () => { + const details = { driverName: "spy", operation: "executeStatement" } as const; + + expect(new ServerException("server", details)).toBeInstanceOf(DriverException); + expect(new SyntaxErrorException("syntax", details)).toBeInstanceOf(ServerException); + expect(new LockWaitTimeoutException("timeout", details)).toBeInstanceOf(ServerException); + expect(new TransactionRolledBack("rolled back", details)).toBeInstanceOf(DriverException); + expect(new ConnectionLost("lost", details)).toBeInstanceOf(ConnectionException); + }); + + it("provides DatabaseRequired factory", () => { + expect(DatabaseRequired.new("connect").message).toBe( + "A database is required for the method: connect.", + ); + }); +}); diff --git a/src/__tests__/exception/driver-exception.test.ts b/src/__tests__/exception/driver-exception.test.ts new file mode 100644 index 0000000..73b6de2 --- /dev/null +++ b/src/__tests__/exception/driver-exception.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { AbstractException as DriverAbstractException } from "../../driver/abstract-exception"; +import { DriverException } from "../../exception/driver-exception"; +import { Query } from "../../query"; + +class DriverFailure extends DriverAbstractException {} + +describe("DriverException (Doctrine parity)", () => { + it("supports doctrine-style wrapping of a driver exception with query context", () => { + const cause = new DriverFailure("duplicate key", "23000", 1062); + const query = new Query("INSERT INTO users (id) VALUES (?)", [1]); + const error = new DriverException(cause, query); + + expect(error.message).toBe("An exception occurred while executing a query: duplicate key"); + expect(error.getQuery()).toBe(query); + expect(error.getSQLState()).toBe("23000"); + expect(error.code).toBe(1062); + expect(error.sql).toBe("INSERT INTO users (id) VALUES (?)"); + expect(error.parameters).toEqual([1]); + expect((error as Error & { cause?: unknown }).cause).toBe(cause); + }); + + it("keeps normalized converter-based constructor path for current converters", () => { + const cause = new Error("raw"); + const error = new DriverException("converted", { + cause, + code: "X", + driverName: "mysql2", + operation: "executeQuery", + parameters: [1], + sql: "SELECT 1", + sqlState: "HY000", + }); + + expect(error.message).toBe("converted"); + expect(error.driverName).toBe("mysql2"); + expect(error.operation).toBe("executeQuery"); + expect(error.getSQLState()).toBe("HY000"); + expect(error.getQuery()).toBeNull(); + expect((error as Error & { cause?: unknown }).cause).toBe(cause); + }); +}); diff --git a/src/__tests__/exception/invalid-column-type.test.ts b/src/__tests__/exception/invalid-column-type.test.ts new file mode 100644 index 0000000..f629686 --- /dev/null +++ b/src/__tests__/exception/invalid-column-type.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { InvalidColumnType } from "../../exception/invalid-column-type"; +import { ColumnLengthRequired } from "../../exception/invalid-column-type/column-length-required"; +import { ColumnPrecisionRequired } from "../../exception/invalid-column-type/column-precision-required"; +import { ColumnScaleRequired } from "../../exception/invalid-column-type/column-scale-required"; +import { ColumnValuesRequired } from "../../exception/invalid-column-type/column-values-required"; +import { MySQLPlatform } from "../../platforms/mysql-platform"; + +describe("InvalidColumnType exceptions", () => { + it("provides doctrine-style length and values factory messages", () => { + const platform = new MySQLPlatform(); + + expect(ColumnLengthRequired.new(platform, "varchar")).toBeInstanceOf(InvalidColumnType); + expect(ColumnLengthRequired.new(platform, "varchar").message).toBe( + "MySQLPlatform requires the length of a varchar column to be specified", + ); + + expect(ColumnValuesRequired.new(platform, "enum")).toBeInstanceOf(InvalidColumnType); + expect(ColumnValuesRequired.new(platform, "enum").message).toBe( + "MySQLPlatform requires the values of a enum column to be specified", + ); + }); + + it("provides doctrine-style precision and scale factory messages", () => { + expect(ColumnPrecisionRequired.new()).toBeInstanceOf(InvalidColumnType); + expect(ColumnPrecisionRequired.new()).toBeInstanceOf(Error); + expect(ColumnPrecisionRequired.new().message).toBe("Column precision is not specified"); + + expect(ColumnScaleRequired.new()).toBeInstanceOf(InvalidColumnType); + expect(ColumnScaleRequired.new().message).toBe("Column scale is not specified"); + }); +}); diff --git a/src/__tests__/exception/top-level-exception-parity.test.ts b/src/__tests__/exception/top-level-exception-parity.test.ts new file mode 100644 index 0000000..c8656be --- /dev/null +++ b/src/__tests__/exception/top-level-exception-parity.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { DatabaseObjectExistsException } from "../../exception/database-object-exists-exception"; +import { DatabaseObjectNotFoundException } from "../../exception/database-object-not-found-exception"; +import { DriverRequired } from "../../exception/driver-required"; +import { InvalidArgumentException } from "../../exception/invalid-argument-exception"; +import { InvalidColumnDeclaration } from "../../exception/invalid-column-declaration"; +import { InvalidColumnIndex } from "../../exception/invalid-column-index"; +import { ColumnPrecisionRequired } from "../../exception/invalid-column-type/column-precision-required"; +import { InvalidDriverClass } from "../../exception/invalid-driver-class"; +import { InvalidWrapperClass } from "../../exception/invalid-wrapper-class"; +import { NoKeyValue } from "../../exception/no-key-value"; +import { ParseError } from "../../exception/parse-error"; +import { SchemaDoesNotExist } from "../../exception/schema-does-not-exist"; +import { ServerException } from "../../exception/server-exception"; +import { UnknownDriver } from "../../exception/unknown-driver"; +import { ParserException } from "../../sql/parser"; + +describe("Top-level Doctrine exception parity", () => { + it("provides doctrine-style argument exception names and factories", () => { + expect(DriverRequired.new()).toBeInstanceOf(InvalidArgumentException); + expect(DriverRequired.new().message).toContain( + 'The options "driver" or "driverClass" are mandatory', + ); + expect(DriverRequired.new("localhost/db")).toBeInstanceOf(DriverRequired); + + expect(UnknownDriver.new("foo", ["mysql2", "pg"])).toBeInstanceOf(InvalidArgumentException); + expect(UnknownDriver.new("foo", ["mysql2", "pg"]).message).toContain( + 'The given driver "foo" is unknown', + ); + + expect(InvalidDriverClass.new("X").message).toContain( + "The given driver class X has to implement the Driver interface.", + ); + expect(InvalidWrapperClass.new("Y").message).toContain( + "The given wrapper class Y has to be a subtype of Connection.", + ); + }); + + it("provides doctrine-style data/result/parse exceptions", () => { + expect(NoKeyValue.fromColumnCount(1).message).toBe( + "Fetching as key-value pairs requires the result to contain at least 2 columns, 1 given.", + ); + + const invalidType = ColumnPrecisionRequired.new(); + const invalidColumnDeclaration = InvalidColumnDeclaration.fromInvalidColumnType( + "price", + invalidType, + ); + + expect(invalidColumnDeclaration.message).toBe('Column "price" has invalid type'); + expect((invalidColumnDeclaration as Error & { cause?: unknown }).cause).toBe(invalidType); + + const invalidColumnIndex = InvalidColumnIndex.new(3); + expect(invalidColumnIndex.message).toBe('Invalid column index "3".'); + + const parserException = new ParserException("parse failure"); + const parseError = ParseError.fromParserException(parserException); + expect(parseError.message).toBe("Unable to parse query."); + expect((parseError as Error & { cause?: unknown }).cause).toBe(parserException); + }); + + it("provides doctrine-style object existence hierarchy", () => { + expect( + new DatabaseObjectExistsException("exists", { driverName: "x", operation: "op" }), + ).toBeInstanceOf(ServerException); + expect(new SchemaDoesNotExist("missing", { driverName: "x", operation: "op" })).toBeInstanceOf( + DatabaseObjectNotFoundException, + ); + }); +}); diff --git a/src/__tests__/logging/middleware.test.ts b/src/__tests__/logging/middleware.test.ts index 7c06b52..ba2e3da 100644 --- a/src/__tests__/logging/middleware.test.ts +++ b/src/__tests__/logging/middleware.test.ts @@ -1,24 +1,20 @@ import { describe, expect, it } from "vitest"; import { Configuration } from "../../configuration"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverManager } from "../../driver-manager"; -import { DriverException } from "../../exception/index"; +import { DriverException } from "../../exception/driver-exception"; import type { Logger } from "../../logging/logger"; import { Middleware } from "../../logging/middleware"; import { ParameterType } from "../../parameter-type"; import { MySQLPlatform } from "../../platforms/mysql-platform"; -import type { CompiledQuery } from "../../types"; +import type { CompiledQuery } from "./query"; interface LogEntry { level: "debug" | "error" | "info" | "warn"; @@ -59,52 +55,67 @@ class NoopExceptionConverter implements ExceptionConverter { } class SpyConnection implements DriverConnection { - public readonly executedQueries: CompiledQuery[] = []; - public readonly executedStatements: CompiledQuery[] = []; - public readonly releasedSavepoints: string[] = []; - public readonly rolledBackSavepoints: string[] = []; - public readonly savepoints: string[] = []; + public readonly queriedSql: string[] = []; + public readonly execSql: string[] = []; + public readonly preparedExecutions: CompiledQuery[] = []; public beginCalls = 0; public closeCalls = 0; public commitCalls = 0; public rollBackCalls = 0; - public async executeQuery(query: CompiledQuery): Promise { - this.executedQueries.push(query); - return { rows: [{ id: 1 }] }; - } + public async prepare(sql: string) { + const boundValues = new Map(); + const boundTypes = new Map(); - public async executeStatement(query: CompiledQuery): Promise { - this.executedStatements.push(query); - return { affectedRows: 1, insertId: 2 }; + return { + bindValue: (param: string | number, value: unknown, type?: unknown) => { + boundValues.set(param, value); + boundTypes.set(param, type); + }, + execute: async () => { + const numericKeys = [...boundValues.keys()] + .filter((key): key is number => typeof key === "number") + .sort((a, b) => a - b); + + this.preparedExecutions.push({ + parameters: numericKeys.map((key) => boundValues.get(key)), + sql, + types: numericKeys.map((key) => boundTypes.get(key)), + }); + + return new ArrayResult([{ id: 1 }], ["id"], 1); + }, + }; } - public async beginTransaction(): Promise { - this.beginCalls += 1; + public async query(sql: string) { + this.queriedSql.push(sql); + return new ArrayResult([{ id: 1 }], ["id"], 1); } - public async commit(): Promise { - this.commitCalls += 1; + public quote(value: string): string { + return `<${value}>`; } - public async rollBack(): Promise { - this.rollBackCalls += 1; + public async exec(sql: string): Promise { + this.execSql.push(sql); + return 1; } - public async createSavepoint(name: string): Promise { - this.savepoints.push(name); + public async lastInsertId(): Promise { + return 2; } - public async releaseSavepoint(name: string): Promise { - this.releasedSavepoints.push(name); + public async beginTransaction(): Promise { + this.beginCalls += 1; } - public async rollbackSavepoint(name: string): Promise { - this.rolledBackSavepoints.push(name); + public async commit(): Promise { + this.commitCalls += 1; } - public quote(value: string): string { - return `<${value}>`; + public async rollBack(): Promise { + this.rollBackCalls += 1; } public async getServerVersion(): Promise { @@ -188,12 +199,12 @@ describe("Logging Middleware", () => { { id: ParameterType.INTEGER, name: ParameterType.STRING }, ); - expect(nativeConnection.executedQueries[0]).toEqual({ + expect(nativeConnection.preparedExecutions[0]).toEqual({ parameters: [1], sql: "SELECT ? AS id", types: [ParameterType.INTEGER], }); - expect(nativeConnection.executedStatements[0]).toEqual({ + expect(nativeConnection.preparedExecutions[1]).toEqual({ parameters: ["alice", 1], sql: "UPDATE users SET name = ? WHERE id = ?", types: [ParameterType.STRING, ParameterType.INTEGER], @@ -202,7 +213,9 @@ describe("Logging Middleware", () => { const queryLog = logger.entries.find( (entry) => entry.level === "debug" && - entry.message === "Executing query: {sql} (parameters: {params}, types: {types})", + entry.message === + "Executing prepared statement: {sql} (parameters: {params}, types: {types})" && + entry.context?.sql === "SELECT ? AS id", ); expect(queryLog?.context).toEqual({ params: [1], @@ -213,7 +226,9 @@ describe("Logging Middleware", () => { const statementLog = logger.entries.find( (entry) => entry.level === "debug" && - entry.message === "Executing statement: {sql} (parameters: {params}, types: {types})", + entry.message === + "Executing prepared statement: {sql} (parameters: {params}, types: {types})" && + entry.context?.sql === "UPDATE users SET name = ? WHERE id = ?", ); expect(statementLog?.context).toEqual({ params: ["alice", 1], @@ -243,9 +258,10 @@ describe("Logging Middleware", () => { expect(nativeConnection.beginCalls).toBe(1); expect(nativeConnection.commitCalls).toBe(1); expect(nativeConnection.rollBackCalls).toBe(0); - expect(nativeConnection.savepoints).toEqual(["DATAZEN_2"]); - expect(nativeConnection.rolledBackSavepoints).toEqual(["DATAZEN_2"]); - expect(nativeConnection.releasedSavepoints).toEqual([]); + expect(nativeConnection.execSql).toEqual([ + "SAVEPOINT DATAZEN_2", + "ROLLBACK TO SAVEPOINT DATAZEN_2", + ]); expect(quoted).toBe(""); expect(nativeConnection.closeCalls).toBe(1); @@ -255,9 +271,14 @@ describe("Logging Middleware", () => { message: "Beginning transaction", }); expect(logger.entries).toContainEqual({ - context: { name: "DATAZEN_2" }, + context: { sql: "SAVEPOINT DATAZEN_2" }, + level: "debug", + message: "Executing statement: {sql}", + }); + expect(logger.entries).toContainEqual({ + context: { sql: "ROLLBACK TO SAVEPOINT DATAZEN_2" }, level: "debug", - message: "Rolling back savepoint {name}", + message: "Executing statement: {sql}", }); expect(logger.entries).toContainEqual({ context: undefined, diff --git a/src/__tests__/package/subpath-exports.test.ts b/src/__tests__/package/subpath-exports.test.ts index ebd2ea8..a55b68d 100644 --- a/src/__tests__/package/subpath-exports.test.ts +++ b/src/__tests__/package/subpath-exports.test.ts @@ -1,46 +1,24 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { ParameterBindingStyle } from "../../driver/index"; -import { ConnectionException } from "../../exception/index"; -import * as Root from "../../index"; -import { ConsoleLogger } from "../../logging/index"; -import { AbstractPlatform } from "../../platforms/index"; -import { Converter } from "../../portability/index"; -import { QueryBuilder } from "../../query/index"; -import { Schema } from "../../schema/module"; -import { Parser } from "../../sql/index"; -import { DsnParser } from "../../tools/index"; -import { Type } from "../../types/index"; +import * as DriverModule from "../../driver"; describe("package subpath namespaces", () => { - it("keeps root exports limited to top-level src modules", () => { - expect(Root.Connection).toBeDefined(); - expect(Root.DriverManager).toBeDefined(); - expect(Root.Query).toBeDefined(); - expect(Root.ParameterType).toBeDefined(); - - expect("DsnParser" in Root).toBe(false); - expect("QueryBuilder" in Root).toBe(false); - expect("AbstractPlatform" in Root).toBe(false); - expect("Type" in Root).toBe(false); - expect("Logging" in Root).toBe(false); - expect("Portability" in Root).toBe(false); - expect("SchemaModule" in Root).toBe(false); + it("does not expose non-doctrine ParameterBindingStyle in public driver API", () => { + expect("ParameterBindingStyle" in DriverModule).toBe(false); }); - it("exposes top-level namespace barrels", () => { - expect(ParameterBindingStyle.NAMED).toBe("named"); - expect(ConnectionException).toBeDefined(); - expect(ConsoleLogger).toBeDefined(); - expect(AbstractPlatform).toBeDefined(); - expect(Converter).toBeDefined(); - expect(QueryBuilder).toBeDefined(); - expect(Schema).toBeDefined(); - expect(Parser).toBeDefined(); - expect(DsnParser).toBeDefined(); - expect(Type).toBeDefined(); + it("omits source namespace barrels during the rewrite phase", () => { + expect(existsSync(new URL("../../driver/index.ts", import.meta.url))).toBe(false); + expect(existsSync(new URL("../../exception/index.ts", import.meta.url))).toBe(false); + expect(existsSync(new URL("../../logging/index.ts", import.meta.url))).toBe(false); + expect(existsSync(new URL("../../platforms/index.ts", import.meta.url))).toBe(false); + expect(existsSync(new URL("../../portability/index.ts", import.meta.url))).toBe(false); + expect(existsSync(new URL("../../query/index.ts", import.meta.url))).toBe(false); + expect(existsSync(new URL("../../sql/index.ts", import.meta.url))).toBe(false); + expect(existsSync(new URL("../../tools/index.ts", import.meta.url))).toBe(false); + expect(existsSync(new URL("../../types/index.ts", import.meta.url))).toBe(false); }); it("declares package subpath exports for namespaces", () => { diff --git a/src/__tests__/parameter/array-parameters-exception.test.ts b/src/__tests__/parameter/array-parameters-exception.test.ts new file mode 100644 index 0000000..a74a32d --- /dev/null +++ b/src/__tests__/parameter/array-parameters-exception.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { MissingNamedParameter } from "../../array-parameters/exception/missing-named-parameter"; +import { MissingPositionalParameter } from "../../array-parameters/exception/missing-positional-parameter"; + +describe("ArrayParameters exceptions parity", () => { + it("creates MissingNamedParameter with Doctrine-compatible message", () => { + const error = MissingNamedParameter.new("id"); + + expect(error).toBeInstanceOf(MissingNamedParameter); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('Named parameter "id" does not have a bound value.'); + }); + + it("creates MissingPositionalParameter with Doctrine-compatible message", () => { + const error = MissingPositionalParameter.new(2); + + expect(error).toBeInstanceOf(MissingPositionalParameter); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Positional parameter at index 2 does not have a bound value."); + }); +}); diff --git a/src/__tests__/parameter/expand-array-parameters.test.ts b/src/__tests__/parameter/expand-array-parameters.test.ts index ea14688..5c542b3 100644 --- a/src/__tests__/parameter/expand-array-parameters.test.ts +++ b/src/__tests__/parameter/expand-array-parameters.test.ts @@ -1,16 +1,12 @@ import { describe, expect, it } from "vitest"; import { ArrayParameterType } from "../../array-parameter-type"; -import { - InvalidParameterException, - MissingNamedParameterException, - MissingPositionalParameterException, - MixedParameterStyleException, -} from "../../exception/index"; +import { MissingNamedParameter } from "../../array-parameters/exception/missing-named-parameter"; +import { MissingPositionalParameter } from "../../array-parameters/exception/missing-positional-parameter"; import { ExpandArrayParameters } from "../../expand-array-parameters"; import { ParameterType } from "../../parameter-type"; +import type { QueryParameterTypes, QueryParameters, QueryScalarParameterType } from "../../query"; import { Parser } from "../../sql/parser"; -import type { QueryParameterTypes, QueryParameters } from "../../types"; function expand( sql: string, @@ -19,7 +15,7 @@ function expand( ): { parameters: unknown[]; sql: string; - types: unknown[]; + types: QueryScalarParameterType[]; } { const visitor = new ExpandArrayParameters(parameters, types); new Parser(true).parse(sql, visitor); @@ -31,33 +27,159 @@ function expand( }; } -describe("ExpandArrayParameters", () => { - it("expands named parameters to positional placeholders", () => { - const result = expand( - "SELECT * FROM users WHERE id = :id AND status = :status", - { id: 10, status: "active" }, - { id: ParameterType.INTEGER, status: ParameterType.STRING }, - ); - - expect(result.sql).toBe("SELECT * FROM users WHERE id = ? AND status = ?"); - expect(result.parameters).toEqual([10, "active"]); - expect(result.types).toEqual([ParameterType.INTEGER, ParameterType.STRING]); - }); - - it("expands array parameters", () => { - const result = expand( - "SELECT * FROM users WHERE id IN (:ids)", - { ids: [1, 2, 3] }, - { ids: ArrayParameterType.INTEGER }, - ); +type ExpandCase = { + name: string; + sql: string; + parameters: QueryParameters; + types: QueryParameterTypes; + expectedSQL: string; + expectedParameters: unknown[]; + expectedTypes: QueryScalarParameterType[]; +}; - expect(result.sql).toBe("SELECT * FROM users WHERE id IN (?, ?, ?)"); - expect(result.parameters).toEqual([1, 2, 3]); - expect(result.types).toEqual([ +const doctrineExpandCases: ExpandCase[] = [ + { + name: "Positional: Very simple with one needle", + sql: "SELECT * FROM Foo WHERE foo IN (?)", + parameters: [[1, 2, 3]], + types: [ArrayParameterType.INTEGER], + expectedSQL: "SELECT * FROM Foo WHERE foo IN (?, ?, ?)", + expectedParameters: [1, 2, 3], + expectedTypes: [ParameterType.INTEGER, ParameterType.INTEGER, ParameterType.INTEGER], + }, + { + name: "Positional: One non-list before and one after list-needle", + sql: "SELECT * FROM Foo WHERE foo = ? AND bar IN (?) AND baz = ?", + parameters: [1, [1, 2, 3], 4], + types: [ParameterType.INTEGER, ArrayParameterType.INTEGER, ParameterType.INTEGER], + expectedSQL: "SELECT * FROM Foo WHERE foo = ? AND bar IN (?, ?, ?) AND baz = ?", + expectedParameters: [1, 1, 2, 3, 4], + expectedTypes: [ + ParameterType.INTEGER, ParameterType.INTEGER, ParameterType.INTEGER, ParameterType.INTEGER, - ]); + ParameterType.INTEGER, + ], + }, + { + name: "Positional: Empty integer array", + sql: "SELECT * FROM Foo WHERE foo IN (?)", + parameters: [[]], + types: [ArrayParameterType.INTEGER], + expectedSQL: "SELECT * FROM Foo WHERE foo IN (NULL)", + expectedParameters: [], + expectedTypes: [], + }, + { + name: "Positional: explicit keys for params and types", + sql: "SELECT * FROM Foo WHERE foo = ? AND bar = ? AND baz = ?", + parameters: Object.assign([], { 1: "bar", 2: "baz", 0: 1 }) as unknown[], + types: Object.assign([], { + 2: ParameterType.STRING, + 1: ParameterType.STRING, + }) as QueryParameterTypes, + expectedSQL: "SELECT * FROM Foo WHERE foo = ? AND bar = ? AND baz = ?", + expectedParameters: Object.assign([], { 1: "bar", 0: 1, 2: "baz" }) as unknown[], + expectedTypes: Object.assign([], { + 1: ParameterType.STRING, + 2: ParameterType.STRING, + }) as QueryScalarParameterType[], + }, + { + name: "Named: Very simple with param int and string", + sql: "SELECT * FROM Foo WHERE foo = :foo AND bar = :bar", + parameters: { bar: "Some String", foo: 1 }, + types: { foo: ParameterType.INTEGER, bar: ParameterType.STRING }, + expectedSQL: "SELECT * FROM Foo WHERE foo = ? AND bar = ?", + expectedParameters: [1, "Some String"], + expectedTypes: [ParameterType.INTEGER, ParameterType.STRING], + }, + { + name: "Named: Very simple with one needle", + sql: "SELECT * FROM Foo WHERE foo IN (:foo)", + parameters: { foo: [1, 2, 3] }, + types: { foo: ArrayParameterType.INTEGER }, + expectedSQL: "SELECT * FROM Foo WHERE foo IN (?, ?, ?)", + expectedParameters: [1, 2, 3], + expectedTypes: [ParameterType.INTEGER, ParameterType.INTEGER, ParameterType.INTEGER], + }, + { + name: "Named: Same name appears twice", + sql: "SELECT * FROM Foo WHERE foo <> :arg AND bar = :arg", + parameters: { arg: "Some String" }, + types: { arg: ParameterType.STRING }, + expectedSQL: "SELECT * FROM Foo WHERE foo <> ? AND bar = ?", + expectedParameters: ["Some String", "Some String"], + expectedTypes: [ParameterType.STRING, ParameterType.STRING], + }, + { + name: "Named: Array parameter reused twice", + sql: "SELECT * FROM Foo WHERE foo IN (:arg) AND NOT bar IN (:arg)", + parameters: { arg: [1, 2, 3] }, + types: { arg: ArrayParameterType.INTEGER }, + expectedSQL: "SELECT * FROM Foo WHERE foo IN (?, ?, ?) AND NOT bar IN (?, ?, ?)", + expectedParameters: [1, 2, 3, 1, 2, 3], + expectedTypes: [ + ParameterType.INTEGER, + ParameterType.INTEGER, + ParameterType.INTEGER, + ParameterType.INTEGER, + ParameterType.INTEGER, + ParameterType.INTEGER, + ], + }, + { + name: "Named: Empty arrays", + sql: "SELECT * FROM Foo WHERE foo IN (:foo) OR bar IN (:bar)", + parameters: { foo: [], bar: [] }, + types: { foo: ArrayParameterType.STRING, bar: ArrayParameterType.STRING }, + expectedSQL: "SELECT * FROM Foo WHERE foo IN (NULL) OR bar IN (NULL)", + expectedParameters: [], + expectedTypes: [], + }, + { + name: "Named: partially implicit types preserve sparse converted types", + sql: "SELECT * FROM Foo WHERE foo IN (:foo) OR bar = :bar OR baz = :baz", + parameters: { foo: [1, 2], bar: "bar", baz: "baz" }, + types: { foo: ArrayParameterType.INTEGER, baz: "string" }, + expectedSQL: "SELECT * FROM Foo WHERE foo IN (?, ?) OR bar = ? OR baz = ?", + expectedParameters: [1, 2, "bar", "baz"], + expectedTypes: Object.assign([], { + 0: ParameterType.INTEGER, + 1: ParameterType.INTEGER, + 3: "string", + }) as QueryScalarParameterType[], + }, + { + name: "Named: no type on second parameter stays untyped", + sql: "SELECT * FROM Foo WHERE foo = :foo OR bar = :bar", + parameters: { foo: "foo", bar: "bar" }, + types: { foo: ParameterType.INTEGER }, + expectedSQL: "SELECT * FROM Foo WHERE foo = ? OR bar = ?", + expectedParameters: ["foo", "bar"], + expectedTypes: [ParameterType.INTEGER], + }, + { + name: "Untyped array value is not expanded", + sql: "SELECT * FROM users WHERE id IN (:ids)", + parameters: { ids: [1, 2, 3] }, + types: { ids: ParameterType.INTEGER }, + expectedSQL: "SELECT * FROM users WHERE id IN (?)", + expectedParameters: [[1, 2, 3]], + expectedTypes: [ParameterType.INTEGER], + }, +]; + +describe("ExpandArrayParameters", () => { + describe.each(doctrineExpandCases)("$name", (testCase) => { + it("rewrites SQL, parameters, and types", () => { + const result = expand(testCase.sql, testCase.parameters, testCase.types); + + expect(result.sql).toBe(testCase.expectedSQL); + expect(result.parameters).toEqual(testCase.expectedParameters); + expect(result.types).toEqual(testCase.expectedTypes); + }); }); it("does not parse placeholders inside string literals", () => { @@ -114,60 +236,10 @@ describe("ExpandArrayParameters", () => { expect(result.parameters).toEqual([10]); }); - it("throws when named and positional parameter styles are mixed", () => { + it("requires exact named parameter keys without colon prefix", () => { expect(() => - expand( - "SELECT * FROM users WHERE id = :id AND email = ?", - { id: 1 }, - { id: ParameterType.INTEGER }, - ), - ).toThrow(MixedParameterStyleException); - }); - - it("throws for missing named parameters", () => { - expect(() => expand("SELECT * FROM users WHERE id = :id", {}, {})).toThrow( - MissingNamedParameterException, - ); - }); - - it("throws for missing positional parameters", () => { - expect(() => expand("SELECT * FROM users WHERE id = ?", [], [])).toThrow( - MissingPositionalParameterException, - ); - }); - - it("throws when array values are used without array parameter types", () => { - expect(() => - expand( - "SELECT * FROM users WHERE id IN (:ids)", - { ids: [1, 2, 3] }, - { ids: ParameterType.INTEGER }, - ), - ).toThrow(InvalidParameterException); - }); - - it("expands empty arrays to NULL without bound values", () => { - const result = expand( - "SELECT * FROM users WHERE id IN (:ids)", - { ids: [] }, - { ids: ArrayParameterType.INTEGER }, - ); - - expect(result.sql).toBe("SELECT * FROM users WHERE id IN (NULL)"); - expect(result.parameters).toEqual([]); - expect(result.types).toEqual([]); - }); - - it("accepts named parameter maps with prefixed keys", () => { - const result = expand( - "SELECT * FROM users WHERE id = :id", - { ":id": 5 }, - { ":id": ParameterType.INTEGER }, - ); - - expect(result.sql).toBe("SELECT * FROM users WHERE id = ?"); - expect(result.parameters).toEqual([5]); - expect(result.types).toEqual([ParameterType.INTEGER]); + expand("SELECT * FROM users WHERE id = :id", { ":id": 5 }, { ":id": ParameterType.INTEGER }), + ).toThrow(MissingNamedParameter); }); it("duplicates values when the same named placeholder appears multiple times", () => { @@ -193,4 +265,63 @@ describe("ExpandArrayParameters", () => { expect(result.parameters).toEqual([1, "ok"]); expect(result.types).toEqual([ParameterType.INTEGER, ParameterType.STRING]); }); + + describe("missing named parameters (Doctrine-style provider)", () => { + const cases: Array<{ + name: string; + sql: string; + params: Record; + types: Record; + }> = [ + { + name: "other parameter only", + sql: "SELECT * FROM foo WHERE bar = :param", + params: { other: "val" }, + types: {}, + }, + { + name: "no parameters", + sql: "SELECT * FROM foo WHERE bar = :param", + params: {}, + types: {}, + }, + { + name: "type exists but param missing", + sql: "SELECT * FROM foo WHERE bar = :param", + params: {}, + types: { bar: ArrayParameterType.INTEGER }, + }, + { + name: "wrong parameter name", + sql: "SELECT * FROM foo WHERE bar = :param", + params: { bar: "value" }, + types: { bar: ArrayParameterType.INTEGER }, + }, + ]; + + describe.each(cases)("$name", ({ sql, params, types }) => { + it("throws MissingNamedParameter", () => { + expect(() => expand(sql, params, types as QueryParameterTypes)).toThrow( + MissingNamedParameter, + ); + }); + }); + }); + + describe("missing positional parameters (Doctrine-style provider)", () => { + const cases = [ + { name: "No parameters", sql: "SELECT * FROM foo WHERE bar = ?", params: [] as unknown[] }, + { + name: "Too few parameters", + sql: "SELECT * FROM foo WHERE bar = ? AND baz = ?", + params: [1] as unknown[], + }, + ]; + + describe.each(cases)("$name", ({ sql, params }) => { + it("throws MissingPositionalParameter", () => { + expect(() => expand(sql, params, [])).toThrow(MissingPositionalParameter); + }); + }); + }); }); diff --git a/src/__tests__/portability/middleware.test.ts b/src/__tests__/portability/middleware.test.ts index 8a82d2f..e5d1de9 100644 --- a/src/__tests__/portability/middleware.test.ts +++ b/src/__tests__/portability/middleware.test.ts @@ -2,24 +2,19 @@ import { describe, expect, it } from "vitest"; import { ColumnCase } from "../../column-case"; import { Configuration } from "../../configuration"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverManager } from "../../driver-manager"; -import { DriverException } from "../../exception/index"; +import { DriverException } from "../../exception/driver-exception"; import { OraclePlatform } from "../../platforms/oracle-platform"; import { SQLServerPlatform } from "../../platforms/sql-server-platform"; import { Connection } from "../../portability/connection"; import { Middleware } from "../../portability/middleware"; -import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -34,18 +29,42 @@ class NoopExceptionConverter implements ExceptionConverter { } class SpyConnection implements DriverConnection { - public queryResult: DriverQueryResult; + public queryResult: { columns?: string[]; rows: Array> }; - constructor(queryResult: DriverQueryResult) { + constructor(queryResult: { columns?: string[]; rows: Array> }) { this.queryResult = queryResult; } - public async executeQuery(_query: CompiledQuery): Promise { - return this.queryResult; + public async prepare(_sql: string) { + return { + bindValue: () => undefined, + execute: async () => + new ArrayResult( + this.queryResult.rows, + this.queryResult.columns ?? [], + this.queryResult.rows.length, + ), + }; + } + + public async query(_sql: string) { + return new ArrayResult( + this.queryResult.rows, + this.queryResult.columns ?? [], + this.queryResult.rows.length, + ); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(_sql: string): Promise { + return 1; } - public async executeStatement(_query: CompiledQuery): Promise { - return { affectedRows: 1 }; + public async lastInsertId(): Promise { + return 1; } public async beginTransaction(): Promise {} diff --git a/src/__tests__/query/query-builder.test.ts b/src/__tests__/query/query-builder.test.ts index d94ad25..f5d7415 100644 --- a/src/__tests__/query/query-builder.test.ts +++ b/src/__tests__/query/query-builder.test.ts @@ -2,25 +2,21 @@ import { describe, expect, it } from "vitest"; import { ArrayParameterType } from "../../array-parameter-type"; import { Connection } from "../../connection"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; -import { DriverException } from "../../exception/index"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; +import { DriverException } from "../../exception/driver-exception"; import { ParameterType } from "../../parameter-type"; import { MySQLPlatform } from "../../platforms/mysql-platform"; import { PlaceHolder, QueryBuilder } from "../../query/query-builder"; import { QueryException } from "../../query/query-exception"; import { UnionType } from "../../query/union-type"; import { Result } from "../../result"; -import type { CompiledQuery, QueryParameterTypes, QueryParameters } from "../../types"; +import type { QueryParameterTypes, QueryParameters } from "./query"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -35,12 +31,27 @@ class NoopExceptionConverter implements ExceptionConverter { } class NoopDriverConnection implements DriverConnection { - public async executeQuery(_query: CompiledQuery): Promise { - return { rows: [] }; + public async prepare(_sql: string) { + return { + bindValue: () => undefined, + execute: async () => new ArrayResult([], [], 0), + }; } - public async executeStatement(_query: CompiledQuery): Promise { - return { affectedRows: 0 }; + public async query(_sql: string) { + return new ArrayResult([], [], 0); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(_sql: string): Promise { + return 0; + } + + public async lastInsertId(): Promise { + return 0; } public async beginTransaction(): Promise {} @@ -109,9 +120,7 @@ class SpyExecutionConnection extends Connection { types: QueryParameterTypes = [], ): Promise { this.queryCalls.push({ params, sql, types }); - return new Result({ - rows: [...this.queryRows], - }); + return new Result(new ArrayResult([...this.queryRows])); } public override async executeStatement( diff --git a/src/__tests__/result/result.test.ts b/src/__tests__/result/result.test.ts index e919808..baed7ac 100644 --- a/src/__tests__/result/result.test.ts +++ b/src/__tests__/result/result.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from "vitest"; -import { NoKeyValueException } from "../../exception/index"; +import { ArrayResult } from "../../driver/array-result"; +import { NoKeyValue } from "../../exception/no-key-value"; import { Result } from "../../result"; function expectUserRow(_row: { id: number; name: string } | false): void {} describe("Result", () => { it("uses class-level row type for fetchAssociative() by default", () => { - const result = new Result<{ id: number; name: string }>({ - rows: [{ id: 1, name: "Alice" }], - }); + const result = new Result<{ id: number; name: string }>( + new ArrayResult([{ id: 1, name: "Alice" }]), + ); const row = result.fetchAssociative(); expectUserRow(row); @@ -17,12 +18,12 @@ describe("Result", () => { }); it("fetches associative rows sequentially", () => { - const result = new Result({ - rows: [ + const result = new Result( + new ArrayResult([ { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, - ], - }); + ]), + ); expect(result.fetchAssociative()).toEqual({ id: 1, name: "Alice" }); expect(result.fetchAssociative()).toEqual({ id: 2, name: "Bob" }); @@ -30,9 +31,7 @@ describe("Result", () => { }); it("returns a clone when fetching associative rows", () => { - const result = new Result({ - rows: [{ id: 1, name: "Alice" }], - }); + const result = new Result(new ArrayResult([{ id: 1, name: "Alice" }])); const row = result.fetchAssociative<{ id: number; name: string }>(); expect(row).toEqual({ id: 1, name: "Alice" }); @@ -44,40 +43,37 @@ describe("Result", () => { }); it("fetches numeric rows using explicit column order", () => { - const result = new Result({ - columns: ["name", "id"], - rows: [{ id: 7, name: "Carol" }], - }); + const result = new Result(new ArrayResult([{ id: 7, name: "Carol" }], ["name", "id"])); expect(result.fetchNumeric<[string, number]>()).toEqual(["Carol", 7]); }); it("fetches single values and first column values", () => { - const result = new Result({ - rows: [ + const result = new Result( + new ArrayResult([ { id: 10, name: "A" }, { id: 20, name: "B" }, - ], - }); + ]), + ); expect(result.fetchOne()).toBe(10); expect(result.fetchFirstColumn()).toEqual([20]); }); it("fetches all numeric and associative rows", () => { - const resultForNumeric = new Result({ - rows: [ + const resultForNumeric = new Result( + new ArrayResult([ { id: 1, name: "A" }, { id: 2, name: "B" }, - ], - }); + ]), + ); - const resultForAssociative = new Result({ - rows: [ + const resultForAssociative = new Result( + new ArrayResult([ { id: 1, name: "A" }, { id: 2, name: "B" }, - ], - }); + ]), + ); expect(resultForNumeric.fetchAllNumeric<[number, string]>()).toEqual([ [1, "A"], @@ -90,12 +86,12 @@ describe("Result", () => { }); it("fetches key/value pairs", () => { - const result = new Result({ - rows: [ + const result = new Result( + new ArrayResult([ { id: "one", value: 100, extra: "x" }, { id: "two", value: 200, extra: "y" }, - ], - }); + ]), + ); expect(result.fetchAllKeyValue()).toEqual({ one: 100, @@ -104,20 +100,18 @@ describe("Result", () => { }); it("throws when key/value fetch has less than two columns", () => { - const result = new Result({ - rows: [{ id: 1 }], - }); + const result = new Result(new ArrayResult([{ id: 1 }])); - expect(() => result.fetchAllKeyValue()).toThrow(NoKeyValueException); + expect(() => result.fetchAllKeyValue()).toThrow(NoKeyValue); }); it("fetches associative rows indexed by first column", () => { - const result = new Result({ - rows: [ + const result = new Result( + new ArrayResult([ { id: "u1", name: "Alice", active: true }, { id: "u2", name: "Bob", active: false }, - ], - }); + ]), + ); expect(result.fetchAllAssociativeIndexed<{ name: string; active: boolean }>()).toEqual({ u1: { active: true, name: "Alice" }, @@ -126,11 +120,7 @@ describe("Result", () => { }); it("supports explicit row and column metadata", () => { - const result = new Result({ - columns: ["id", "name"], - rowCount: 42, - rows: [], - }); + const result = new Result(new ArrayResult([], ["id", "name"], 42)); expect(result.rowCount()).toBe(42); expect(result.columnCount()).toBe(2); @@ -139,9 +129,7 @@ describe("Result", () => { }); it("releases rows when free() is called", () => { - const result = new Result({ - rows: [{ id: 1 }], - }); + const result = new Result(new ArrayResult([{ id: 1 }])); result.free(); expect(result.fetchAssociative()).toBe(false); diff --git a/src/__tests__/schema/schema-exception-parity.test.ts b/src/__tests__/schema/schema-exception-parity.test.ts index ad0f0c7..5dfb3a1 100644 --- a/src/__tests__/schema/schema-exception-parity.test.ts +++ b/src/__tests__/schema/schema-exception-parity.test.ts @@ -5,39 +5,37 @@ import { ObjectDoesNotExist } from "../../schema/collections/exception/object-do import { OptionallyUnqualifiedNamedObjectSet } from "../../schema/collections/optionally-unqualified-named-object-set"; import { UnqualifiedNamedObjectSet } from "../../schema/collections/unqualified-named-object-set"; import { ColumnEditor } from "../../schema/column-editor"; -import { - ColumnAlreadyExists, - ColumnDoesNotExist, - ForeignKeyDoesNotExist, - IncomparableNames, - IndexAlreadyExists, - IndexDoesNotExist, - IndexNameInvalid, - InvalidColumnDefinition, - InvalidForeignKeyConstraintDefinition, - InvalidIdentifier, - InvalidIndexDefinition, - InvalidPrimaryKeyConstraintDefinition, - InvalidSequenceDefinition, - InvalidState, - InvalidTableDefinition, - InvalidTableModification, - InvalidTableName, - InvalidUniqueConstraintDefinition, - InvalidViewDefinition, - NamespaceAlreadyExists, - NotImplemented, - PrimaryKeyAlreadyExists, - InvalidName as SchemaInvalidName, - SequenceAlreadyExists, - SequenceDoesNotExist, - TableAlreadyExists, - TableDoesNotExist, - UniqueConstraintDoesNotExist, - UnknownColumnOption, - UnsupportedName, - UnsupportedSchema, -} from "../../schema/exception"; +import { ColumnAlreadyExists } from "../../schema/exception/column-already-exists"; +import { ColumnDoesNotExist } from "../../schema/exception/column-does-not-exist"; +import { ForeignKeyDoesNotExist } from "../../schema/exception/foreign-key-does-not-exist"; +import { IncomparableNames } from "../../schema/exception/incomparable-names"; +import { IndexAlreadyExists } from "../../schema/exception/index-already-exists"; +import { IndexDoesNotExist } from "../../schema/exception/index-does-not-exist"; +import { IndexNameInvalid } from "../../schema/exception/index-name-invalid"; +import { InvalidColumnDefinition } from "../../schema/exception/invalid-column-definition"; +import { InvalidForeignKeyConstraintDefinition } from "../../schema/exception/invalid-foreign-key-constraint-definition"; +import { InvalidIdentifier } from "../../schema/exception/invalid-identifier"; +import { InvalidIndexDefinition } from "../../schema/exception/invalid-index-definition"; +import { InvalidName as SchemaInvalidName } from "../../schema/exception/invalid-name"; +import { InvalidPrimaryKeyConstraintDefinition } from "../../schema/exception/invalid-primary-key-constraint-definition"; +import { InvalidSequenceDefinition } from "../../schema/exception/invalid-sequence-definition"; +import { InvalidState } from "../../schema/exception/invalid-state"; +import { InvalidTableDefinition } from "../../schema/exception/invalid-table-definition"; +import { InvalidTableModification } from "../../schema/exception/invalid-table-modification"; +import { InvalidTableName } from "../../schema/exception/invalid-table-name"; +import { InvalidUniqueConstraintDefinition } from "../../schema/exception/invalid-unique-constraint-definition"; +import { InvalidViewDefinition } from "../../schema/exception/invalid-view-definition"; +import { NamespaceAlreadyExists } from "../../schema/exception/namespace-already-exists"; +import { NotImplemented } from "../../schema/exception/not-implemented"; +import { PrimaryKeyAlreadyExists } from "../../schema/exception/primary-key-already-exists"; +import { SequenceAlreadyExists } from "../../schema/exception/sequence-already-exists"; +import { SequenceDoesNotExist } from "../../schema/exception/sequence-does-not-exist"; +import { TableAlreadyExists } from "../../schema/exception/table-already-exists"; +import { TableDoesNotExist } from "../../schema/exception/table-does-not-exist"; +import { UniqueConstraintDoesNotExist } from "../../schema/exception/unique-constraint-does-not-exist"; +import { UnknownColumnOption } from "../../schema/exception/unknown-column-option"; +import { UnsupportedName } from "../../schema/exception/unsupported-name"; +import { UnsupportedSchema } from "../../schema/exception/unsupported-schema"; import { ForeignKeyConstraintEditor } from "../../schema/foreign-key-constraint-editor"; import { IndexEditor } from "../../schema/index-editor"; import { ExpectedDot } from "../../schema/name/parser/exception/expected-dot"; diff --git a/src/__tests__/schema/schema-manager.test.ts b/src/__tests__/schema/schema-manager.test.ts index 80450fe..7b0dad3 100644 --- a/src/__tests__/schema/schema-manager.test.ts +++ b/src/__tests__/schema/schema-manager.test.ts @@ -2,22 +2,17 @@ import { describe, expect, it } from "vitest"; import { Configuration } from "../../configuration"; import { Connection } from "../../connection"; -import { - type Driver, - type DriverConnection, - type DriverExecutionResult, - type DriverQueryResult, - ParameterBindingStyle, -} from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; +import { ArrayResult } from "../../driver/array-result"; +import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverException } from "../../exception/driver-exception"; import { MySQLPlatform } from "../../platforms/mysql-platform"; import { AbstractSchemaManager } from "../../schema/abstract-schema-manager"; import { SchemaManagerFactory } from "../../schema/schema-manager-factory"; -import type { CompiledQuery } from "../../types"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -32,24 +27,35 @@ class NoopExceptionConverter implements ExceptionConverter { } class SchemaSpyConnection implements DriverConnection { - public async executeQuery(query: CompiledQuery): Promise { - if (query.sql.includes("TABLE_TYPE = 'BASE TABLE'")) { - return { - rows: [{ TABLE_NAME: "users" }, { TABLE_NAME: "posts" }], - }; + public async prepare(sql: string) { + return { + bindValue: () => undefined, + execute: async () => this.query(sql), + }; + } + + public async query(sql: string) { + if (sql.includes("TABLE_TYPE = 'BASE TABLE'")) { + return new ArrayResult([{ TABLE_NAME: "users" }, { TABLE_NAME: "posts" }], ["TABLE_NAME"]); } - if (query.sql.includes("TABLE_TYPE = 'VIEW'")) { - return { - rows: [{ TABLE_NAME: "active_users" }], - }; + if (sql.includes("TABLE_TYPE = 'VIEW'")) { + return new ArrayResult([{ TABLE_NAME: "active_users" }], ["TABLE_NAME"]); } - return { rows: [] }; + return new ArrayResult([], [], 0); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(_sql: string): Promise { + return 0; } - public async executeStatement(_query: CompiledQuery): Promise { - return { affectedRows: 0, insertId: null }; + public async lastInsertId(): Promise { + return 0; } public async beginTransaction(): Promise {} diff --git a/src/__tests__/schema/schema-name-introspection-parity.test.ts b/src/__tests__/schema/schema-name-introspection-parity.test.ts index 6ca6dec..5dd5cda 100644 --- a/src/__tests__/schema/schema-name-introspection-parity.test.ts +++ b/src/__tests__/schema/schema-name-introspection-parity.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest"; import { AbstractPlatform } from "../../platforms/abstract-platform"; import { Column } from "../../schema/column"; -import { IncomparableNames, InvalidIndexDefinition } from "../../schema/exception"; +import { IncomparableNames } from "../../schema/exception/incomparable-names"; +import { InvalidIndexDefinition } from "../../schema/exception/invalid-index-definition"; import { Deferrability } from "../../schema/foreign-key-constraint/deferrability"; import { MatchType } from "../../schema/foreign-key-constraint/match-type"; import { ReferentialAction } from "../../schema/foreign-key-constraint/referential-action"; -import { IndexType, IndexedColumn } from "../../schema/index/index"; +import { IndexType } from "../../schema/index/index-type"; +import { IndexedColumn } from "../../schema/index/indexed-column"; import { ForeignKeyConstraintColumnMetadataProcessor } from "../../schema/introspection/metadata-processor/foreign-key-constraint-column-metadata-processor"; import { IndexColumnMetadataProcessor } from "../../schema/introspection/metadata-processor/index-column-metadata-processor"; import { PrimaryKeyConstraintColumnMetadataProcessor } from "../../schema/introspection/metadata-processor/primary-key-constraint-column-metadata-processor"; diff --git a/src/__tests__/statement/statement.test.ts b/src/__tests__/statement/statement.test.ts index 92c45d7..2f28907 100644 --- a/src/__tests__/statement/statement.test.ts +++ b/src/__tests__/statement/statement.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "vitest"; -import { MixedParameterStyleException } from "../../exception/index"; import { ParameterType } from "../../parameter-type"; import { Result } from "../../result"; import { Statement, type StatementExecutor } from "../../statement"; -import type { QueryParameterTypes, QueryParameters } from "../../types"; +import type { QueryParameterTypes, QueryParameters } from "./query"; class SpyExecutor implements StatementExecutor { public lastQueryCall: @@ -101,12 +100,17 @@ describe("Statement", () => { }); }); - it("throws when positional and named bindings are mixed", async () => { - const statement = new Statement(new SpyExecutor(), "SELECT * FROM users WHERE id = :id"); + it("supports mixed positional and named bindings", async () => { + const executor = new SpyExecutor(); + const mixed = new Statement(executor, "SELECT * FROM users WHERE id = :id AND parent_id = ?"); - statement.bindValue(1, 10).bindValue("id", 10); + await mixed.bindValue(1, 10).bindValue("id", 10).executeQuery(); - await expect(statement.executeQuery()).rejects.toThrow(MixedParameterStyleException); + expect(executor.lastQueryCall).toEqual({ + params: { 0: 10, id: 10 }, + sql: "SELECT * FROM users WHERE id = :id AND parent_id = ?", + types: { 0: ParameterType.STRING, id: ParameterType.STRING }, + }); }); it("executes statement calls through executor", async () => { diff --git a/src/__tests__/tools/dsn-parser.test.ts b/src/__tests__/tools/dsn-parser.test.ts index b4fdf9e..6580176 100644 --- a/src/__tests__/tools/dsn-parser.test.ts +++ b/src/__tests__/tools/dsn-parser.test.ts @@ -1,13 +1,12 @@ import { describe, expect, it } from "vitest"; -import { type Driver, type DriverConnection, ParameterBindingStyle } from "../../driver"; +import { type Driver, type DriverConnection } from "../../driver"; import type { ExceptionConverter } from "../../driver/api/exception-converter"; -import { MalformedDsnException } from "../../exception/index"; +import { MalformedDsnException } from "../../exception/malformed-dsn-exception"; import { DsnParser } from "../../tools/dsn-parser"; class DummyDriver implements Driver { public readonly name = "dummy"; - public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; public async connect(_params: Record): Promise { throw new Error("not implemented"); diff --git a/src/__tests__/types/types.test.ts b/src/__tests__/types/types.test.ts index 6036887..7c928d6 100644 --- a/src/__tests__/types/types.test.ts +++ b/src/__tests__/types/types.test.ts @@ -9,15 +9,17 @@ import { TypesAlreadyExists, UnknownColumnType, } from "../../types/exception/index"; -import { Type } from "../../types/index"; import { JsonType } from "../../types/json-type"; +import { registerBuiltInTypes } from "../../types/register-built-in-types"; import { SimpleArrayType } from "../../types/simple-array-type"; import { StringType } from "../../types/string-type"; +import { Type } from "../../types/type"; import { TypeRegistry } from "../../types/type-registry"; import { Types } from "../../types/types"; describe("Types subsystem", () => { it("loads built-in types from the global registry", () => { + registerBuiltInTypes(); expect(Type.hasType(Types.STRING)).toBe(true); expect(Type.hasType(Types.INTEGER)).toBe(true); diff --git a/src/array-parameters/exception.ts b/src/array-parameters/exception.ts new file mode 100644 index 0000000..3156079 --- /dev/null +++ b/src/array-parameters/exception.ts @@ -0,0 +1 @@ +export interface Exception extends Error {} diff --git a/src/array-parameters/exception/missing-named-parameter.ts b/src/array-parameters/exception/missing-named-parameter.ts new file mode 100644 index 0000000..e97e875 --- /dev/null +++ b/src/array-parameters/exception/missing-named-parameter.ts @@ -0,0 +1,11 @@ +import type { Exception } from "../exception"; + +export class MissingNamedParameter extends Error implements Exception { + public static new(name: string): MissingNamedParameter { + return new MissingNamedParameter(name); + } + + constructor(name: string) { + super(`Named parameter "${name}" does not have a bound value.`); + } +} diff --git a/src/array-parameters/exception/missing-positional-parameter.ts b/src/array-parameters/exception/missing-positional-parameter.ts new file mode 100644 index 0000000..3276124 --- /dev/null +++ b/src/array-parameters/exception/missing-positional-parameter.ts @@ -0,0 +1,11 @@ +import type { Exception } from "../exception"; + +export class MissingPositionalParameter extends Error implements Exception { + public static new(index: number): MissingPositionalParameter { + return new MissingPositionalParameter(index); + } + + constructor(index: number) { + super(`Positional parameter at index ${index} does not have a bound value.`); + } +} diff --git a/src/configuration.ts b/src/configuration.ts index 4278e86..53fb42b 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,4 +1,4 @@ -import type { DriverMiddleware } from "./driver"; +import type { Middleware as DriverMiddleware } from "./driver/middleware"; import type { SchemaManagerFactory } from "./schema/schema-manager-factory"; interface ConfigurationOptions { @@ -9,10 +9,10 @@ interface ConfigurationOptions { } export class Configuration { - private autoCommit: boolean; - private middlewares: DriverMiddleware[]; - private schemaAssetsFilter: (assetName: string) => boolean; - private schemaManagerFactory: SchemaManagerFactory | null; + private autoCommit: boolean = true; + private middlewares: DriverMiddleware[] = []; + protected schemaAssetsFilter: (assetName: string) => boolean; + private schemaManagerFactory: SchemaManagerFactory | null = null; constructor(options?: ConfigurationOptions) { this.autoCommit = options?.autoCommit ?? true; diff --git a/src/connection-exception.ts b/src/connection-exception.ts new file mode 100644 index 0000000..817259d --- /dev/null +++ b/src/connection-exception.ts @@ -0,0 +1,3 @@ +import { DriverException } from "./exception/driver-exception"; + +export class ConnectionException extends DriverException {} diff --git a/src/connection.ts b/src/connection.ts index c57d5a2..a2d8f3d 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,19 +1,26 @@ import { ArrayParameterType } from "./array-parameter-type"; import { Configuration } from "./configuration"; -import { type Driver, type DriverConnection, ParameterBindingStyle } from "./driver"; +import { StaticServerVersionProvider } from "./connection/static-server-version-provider"; +import { type Driver, type DriverConnection } from "./driver"; import type { ExceptionConverter } from "./driver/api/exception-converter"; -import { - ConnectionException, - DbalException, - MissingPositionalParameterException, - MixedParameterStyleException, - NestedTransactionsNotSupportedException, - NoActiveTransactionException, - RollbackOnlyException, -} from "./exception/index"; +import { ParameterBindingStyle } from "./driver/internal-parameter-binding-style"; +import type { Statement as DriverStatement } from "./driver/statement"; +import type { Exception as DoctrineException } from "./exception"; +import { isDoctrineException } from "./exception/_util"; +import { CommitFailedRollbackOnly } from "./exception/commit-failed-rollback-only"; +import { ConnectionException } from "./exception/connection-exception"; +import { MissingPositionalParameterException } from "./exception/missing-positional-parameter-exception"; +import { NoActiveTransaction } from "./exception/no-active-transaction"; +import { SavepointsNotSupported } from "./exception/savepoints-not-supported"; import { ExpandArrayParameters } from "./expand-array-parameters"; import { ParameterType } from "./parameter-type"; import { AbstractPlatform } from "./platforms/abstract-platform"; +import type { + QueryParameterType, + QueryParameterTypes, + QueryParameters, + QueryScalarParameterType, +} from "./query"; import { Query } from "./query"; import { ExpressionBuilder } from "./query/expression/expression-builder"; import { QueryBuilder } from "./query/query-builder"; @@ -24,15 +31,7 @@ import type { SchemaManagerFactory } from "./schema/schema-manager-factory"; import type { ServerVersionProvider } from "./server-version-provider"; import { Parser, type SQLParser, type Visitor } from "./sql/parser"; import { Statement, type StatementExecutor } from "./statement"; -import { StaticServerVersionProvider } from "./static-server-version-provider"; -import type { - CompiledQuery, - QueryParameterType, - QueryParameterTypes, - QueryParameters, - QueryScalarParameterType, -} from "./types"; -import { Type } from "./types/index"; +import { Type } from "./types/type"; type DataMap = Record; @@ -47,7 +46,6 @@ export class Connection implements StatementExecutor { private driverConnection: DriverConnection | null = null; private transactionNestingLevel = 0; private rollbackOnly = false; - private latestInsertId: number | string | null = null; private exceptionConverter: ExceptionConverter | null = null; private databasePlatform: AbstractPlatform | null = null; private parser: SQLParser | null = null; @@ -150,7 +148,7 @@ export class Connection implements StatementExecutor { const query = new Query(sql, params, types); try { - const result = await (await this.connect()).executeQuery(compiledQuery); + const result = await this.executeDriverQuery(compiledQuery); return new Result(result); } catch (error) { throw this.convertException(error, "executeQuery", query); @@ -164,7 +162,7 @@ export class Connection implements StatementExecutor { const compiledQuery = this.compileQuery(query.sql, boundParams, boundTypes); try { - const result = await (await this.connect()).executeQuery(compiledQuery); + const result = await this.executeDriverQuery(compiledQuery); return new Result(result); } catch (error) { throw this.convertException(error, "executeQuery", query); @@ -181,9 +179,7 @@ export class Connection implements StatementExecutor { const query = new Query(sql, params, types); try { - const result = await (await this.connect()).executeStatement(compiledQuery); - this.latestInsertId = result.insertId ?? null; - return result.affectedRows; + return await this.executeDriverStatement(compiledQuery); } catch (error) { throw this.convertException(error, "executeStatement", query); } @@ -194,9 +190,7 @@ export class Connection implements StatementExecutor { const compiledQuery = this.compileQuery(query.sql, boundParams, boundTypes); try { - const result = await (await this.connect()).executeStatement(compiledQuery); - this.latestInsertId = result.insertId ?? null; - return result.affectedRows; + return await this.executeDriverStatement(compiledQuery); } catch (error) { throw this.convertException(error, "executeStatement", query); } @@ -340,27 +334,40 @@ export class Connection implements StatementExecutor { return; } + const platform = this.getDatabasePlatform(); + if (!platform.supportsSavepoints()) { + throw SavepointsNotSupported.new({ driverName: this.getDriverName() }); + } + const savepointName = this.getNestedTransactionSavePointName( this.transactionNestingLevel + 1, ); - if (connection.createSavepoint === undefined) { - throw new NestedTransactionsNotSupportedException(this.driver.name); - } - - await connection.createSavepoint(savepointName); + await this.executeStatement(platform.createSavePoint(savepointName)); this.transactionNestingLevel += 1; } catch (error) { throw this.convertException(error, "beginTransaction"); } } + public async createSavepoint(savepoint: string): Promise { + await this.executeStatement(this.getDatabasePlatform().createSavePoint(savepoint)); + } + + public async releaseSavepoint(savepoint: string): Promise { + await this.executeStatement(this.getDatabasePlatform().releaseSavePoint(savepoint)); + } + + public async rollbackSavepoint(savepoint: string): Promise { + await this.executeStatement(this.getDatabasePlatform().rollbackSavePoint(savepoint)); + } + public async commit(): Promise { if (this.transactionNestingLevel === 0) { - throw new NoActiveTransactionException(); + throw NoActiveTransaction.new(); } if (this.rollbackOnly) { - throw new RollbackOnlyException(); + throw CommitFailedRollbackOnly.new(); } const connection = await this.connect(); @@ -369,12 +376,15 @@ export class Connection implements StatementExecutor { if (this.transactionNestingLevel === 1) { await connection.commit(); } else { - if (connection.releaseSavepoint === undefined) { - throw new NestedTransactionsNotSupportedException(this.driver.name); + const platform = this.getDatabasePlatform(); + if (!platform.supportsSavepoints() || !platform.supportsReleaseSavepoints()) { + throw SavepointsNotSupported.new({ driverName: this.getDriverName() }); } - await connection.releaseSavepoint( - this.getNestedTransactionSavePointName(this.transactionNestingLevel), + await this.executeStatement( + platform.releaseSavePoint( + this.getNestedTransactionSavePointName(this.transactionNestingLevel), + ), ); } } catch (error) { @@ -386,7 +396,7 @@ export class Connection implements StatementExecutor { public async rollBack(): Promise { if (this.transactionNestingLevel === 0) { - throw new NoActiveTransactionException(); + throw NoActiveTransaction.new(); } try { @@ -407,12 +417,15 @@ export class Connection implements StatementExecutor { return; } - if (connection.rollbackSavepoint === undefined) { - throw new NestedTransactionsNotSupportedException(this.driver.name); + const platform = this.getDatabasePlatform(); + if (!platform.supportsSavepoints()) { + throw SavepointsNotSupported.new({ driverName: this.getDriverName() }); } - await connection.rollbackSavepoint( - this.getNestedTransactionSavePointName(this.transactionNestingLevel), + await this.executeStatement( + platform.rollbackSavePoint( + this.getNestedTransactionSavePointName(this.transactionNestingLevel), + ), ); this.transactionNestingLevel -= 1; } catch (error) { @@ -422,7 +435,7 @@ export class Connection implements StatementExecutor { public setRollbackOnly(): void { if (this.transactionNestingLevel === 0) { - throw new NoActiveTransactionException(); + throw NoActiveTransaction.new(); } this.rollbackOnly = true; @@ -430,7 +443,7 @@ export class Connection implements StatementExecutor { public isRollbackOnly(): boolean { if (this.transactionNestingLevel === 0) { - throw new NoActiveTransactionException(); + throw NoActiveTransaction.new(); } return this.rollbackOnly; @@ -450,7 +463,11 @@ export class Connection implements StatementExecutor { } public async lastInsertId(): Promise { - return this.latestInsertId; + try { + return await (await this.connect()).lastInsertId(); + } catch (error) { + throw this.convertException(error, "lastInsertId"); + } } public async quote(value: string): Promise { @@ -473,10 +490,14 @@ export class Connection implements StatementExecutor { } try { - await this.driverConnection.close(); - this.driverConnection = null; - this.transactionNestingLevel = 0; - this.rollbackOnly = false; + const closableConnection = this.driverConnection as DriverConnection & { + close?: () => Promise; + }; + + if (closableConnection.close !== undefined) { + await closableConnection.close(); + } + this.resetConnectionState(); } catch (error) { throw this.convertException(error, "close"); } @@ -549,14 +570,75 @@ export class Connection implements StatementExecutor { } } - private compileQuery( - sql: string, - params: QueryParameters, - types: QueryParameterTypes, - ): CompiledQuery { + private async executeDriverQuery(compiledQuery: Query) { + const connection = await this.connect(); + + if (!this.hasBoundParameters(compiledQuery.parameters)) { + return connection.query(compiledQuery.sql); + } + + const statement = await connection.prepare(compiledQuery.sql); + this.bindDriverParameters(statement, compiledQuery.parameters, compiledQuery.types); + + return statement.execute(); + } + + private async executeDriverStatement(compiledQuery: Query): Promise { + const connection = await this.connect(); + + if (!this.hasBoundParameters(compiledQuery.parameters)) { + const affectedRows = await connection.exec(compiledQuery.sql); + return typeof affectedRows === "number" ? affectedRows : Number(affectedRows); + } + + const statement = await connection.prepare(compiledQuery.sql); + this.bindDriverParameters(statement, compiledQuery.parameters, compiledQuery.types); + + const result = await statement.execute(); + + try { + const affectedRows = result.rowCount(); + return typeof affectedRows === "number" ? affectedRows : Number(affectedRows); + } finally { + result.free(); + } + } + + private bindDriverParameters( + statement: DriverStatement, + parameters: Query["parameters"], + types: Query["types"], + ): void { + if (Array.isArray(parameters) && Array.isArray(types)) { + for (let index = 0; index < parameters.length; index += 1) { + const type = (types[index] ?? ParameterType.STRING) as ParameterType; + statement.bindValue(index + 1, parameters[index], type); + } + + return; + } + + if (!Array.isArray(parameters) && !Array.isArray(types)) { + for (const [name, value] of Object.entries(parameters)) { + statement.bindValue(name, value, (types[name] ?? ParameterType.STRING) as ParameterType); + } + + return; + } + } + + private hasBoundParameters(parameters: Query["parameters"]): boolean { + if (Array.isArray(parameters)) { + return parameters.length > 0; + } + + return Object.keys(parameters).length > 0; + } + + private compileQuery(sql: string, params: QueryParameters, types: QueryParameterTypes): Query { const expanded = this.expandArrayParameters(sql, params, types); - if (this.driver.bindingStyle === ParameterBindingStyle.POSITIONAL) { + if (this.getDriverBindingStyle() === ParameterBindingStyle.POSITIONAL) { return expanded; } @@ -573,10 +655,6 @@ export class Connection implements StatementExecutor { (Array.isArray(types) && types.some((type) => this.isArrayParameterType(type))); if (!needsExpansion) { - if (!Array.isArray(params)) { - throw new MixedParameterStyleException(); - } - return { parameters: [...params], sql, @@ -598,7 +676,7 @@ export class Connection implements StatementExecutor { sql: string, parameters: unknown[], types: QueryScalarParameterType[], - ): CompiledQuery { + ): Query { const sqlParts: string[] = []; const namedParameters: DataMap = {}; const namedTypes: Record = {}; @@ -607,7 +685,16 @@ export class Connection implements StatementExecutor { const visitor: Visitor = { acceptNamedParameter: (): void => { - throw new MixedParameterStyleException(); + if (!Object.hasOwn(parameters, parameterIndex)) { + throw new MissingPositionalParameterException(parameterIndex); + } + + bindCounter += 1; + const name = `p${bindCounter}`; + sqlParts.push(`@${name}`); + namedParameters[name] = parameters[parameterIndex]; + namedTypes[name] = types[parameterIndex] ?? ParameterType.STRING; + parameterIndex += 1; }, acceptOther: (fragment: string): void => { sqlParts.push(fragment); @@ -662,6 +749,30 @@ export class Connection implements StatementExecutor { return this.parser; } + private getDriverBindingStyle(): ParameterBindingStyle { + const bindingStyle = (this.driver as { bindingStyle?: unknown }).bindingStyle; + + if (bindingStyle === ParameterBindingStyle.NAMED) { + return ParameterBindingStyle.NAMED; + } + + return ParameterBindingStyle.POSITIONAL; + } + + private getDriverName(): string { + const explicitName = (this.driver as { name?: unknown }).name; + if (typeof explicitName === "string" && explicitName.length > 0) { + return explicitName; + } + + const ctorName = (this.driver as { constructor?: { name?: unknown } }).constructor?.name; + if (typeof ctorName === "string" && ctorName.length > 0) { + return ctorName; + } + + return "driver"; + } + public convertToDatabaseValue(value: unknown, type: string): unknown { return Type.getType(type).convertToDatabaseValue(value, this.getDatabasePlatform()); } @@ -705,7 +816,7 @@ export class Connection implements StatementExecutor { } try { - this.driverConnection = await this.driver.connect(this.params); + this.driverConnection = await this.performConnect(); if (!this.autoCommit) { await this.beginTransaction(); @@ -717,8 +828,26 @@ export class Connection implements StatementExecutor { } } - private convertException(error: unknown, operation: string, query?: Query): DbalException { - if (error instanceof DbalException) { + protected async performConnect(_connectionName?: string): Promise { + return this.driver.connect(this.params); + } + + protected getWrappedDriverConnection(): DriverConnection | null { + return this.driverConnection; + } + + protected setWrappedDriverConnection(connection: DriverConnection | null): void { + this.driverConnection = connection; + } + + protected resetConnectionState(): void { + this.driverConnection = null; + this.transactionNestingLevel = 0; + this.rollbackOnly = false; + } + + protected convertException(error: unknown, operation: string, query?: Query): DoctrineException { + if (isDoctrineException(error)) { return error; } @@ -726,9 +855,7 @@ export class Connection implements StatementExecutor { const converted = this.exceptionConverter.convert(error, { operation, query }); if (converted instanceof ConnectionException) { - this.driverConnection = null; - this.transactionNestingLevel = 0; - this.rollbackOnly = false; + this.resetConnectionState(); } return converted; diff --git a/src/connections/primary-read-replica-connection.ts b/src/connections/primary-read-replica-connection.ts new file mode 100644 index 0000000..b40f7d0 --- /dev/null +++ b/src/connections/primary-read-replica-connection.ts @@ -0,0 +1,246 @@ +import { Configuration } from "../configuration"; +import { Connection } from "../connection"; +import type { Driver, DriverConnection } from "../driver"; +import type { QueryParameterTypes, QueryParameters } from "../query"; +import type { Statement } from "../statement"; + +type OverrideParams = Record; + +export interface PrimaryReadReplicaConnectionParams extends Record { + primary: OverrideParams; + replica: OverrideParams[]; + keepReplica?: boolean; +} + +type NamedConnection = "primary" | "replica"; + +/** + * Primary-replica DBAL connection wrapper (Doctrine-style). + */ +export class PrimaryReadReplicaConnection extends Connection { + protected connections: Record = { + primary: null, + replica: null, + }; + + protected keepReplica = false; + + constructor( + params: PrimaryReadReplicaConnectionParams, + driver: Driver, + configuration: Configuration = new Configuration(), + ) { + if (params.primary === undefined || params.replica === undefined) { + throw new TypeError("primary or replica configuration missing"); + } + + if (!Array.isArray(params.replica) || params.replica.length === 0) { + throw new TypeError("You have to configure at least one replica."); + } + + const normalizedParams: PrimaryReadReplicaConnectionParams = { + ...params, + primary: { ...params.primary }, + replica: params.replica.map((replica) => ({ ...replica })), + }; + + if (typeof params.driver === "string") { + normalizedParams.primary.driver = params.driver; + + normalizedParams.replica = normalizedParams.replica.map((replica) => ({ + ...replica, + driver: params.driver, + })); + } + + super(normalizedParams, driver, configuration); + + this.keepReplica = Boolean(params.keepReplica); + } + + public isConnectedToPrimary(): boolean { + const current = this.getWrappedDriverConnection(); + + return current !== null && current === this.connections.primary; + } + + public override async connect(): Promise; + public async connect(connectionName: string | null): Promise; + public override async connect(connectionName?: string | null): Promise { + if (connectionName !== undefined && connectionName !== null) { + throw new TypeError( + "Passing a connection name as first argument is not supported anymore. Use ensureConnectedToPrimary()/ensureConnectedToReplica() instead.", + ); + } + + return super.connect(); + } + + public async ensureConnectedToPrimary(): Promise { + await this.performConnect("primary"); + } + + public async ensureConnectedToReplica(): Promise { + await this.performConnect("replica"); + } + + public override async executeStatement( + sql: string, + params: QueryParameters = [], + types: QueryParameterTypes = [], + ): Promise { + await this.ensureConnectedToPrimary(); + + return super.executeStatement(sql, params, types); + } + + public override async beginTransaction(): Promise { + await this.ensureConnectedToPrimary(); + + await super.beginTransaction(); + } + + public override async commit(): Promise { + await this.ensureConnectedToPrimary(); + + await super.commit(); + } + + public override async rollBack(): Promise { + await this.ensureConnectedToPrimary(); + + await super.rollBack(); + } + + public override async createSavepoint(savepoint: string): Promise { + await this.ensureConnectedToPrimary(); + + await super.createSavepoint(savepoint); + } + + public override async releaseSavepoint(savepoint: string): Promise { + await this.ensureConnectedToPrimary(); + + await super.releaseSavepoint(savepoint); + } + + public override async rollbackSavepoint(savepoint: string): Promise { + await this.ensureConnectedToPrimary(); + + await super.rollbackSavepoint(savepoint); + } + + public override async prepare(sql: string): Promise { + await this.ensureConnectedToPrimary(); + + return super.prepare(sql); + } + + public override async close(): Promise { + const current = this.getWrappedDriverConnection(); + const uniqueConnections = new Set(); + + if (this.connections.primary !== null) { + uniqueConnections.add(this.connections.primary); + } + + if (this.connections.replica !== null) { + uniqueConnections.add(this.connections.replica); + } + + if (current !== null) { + await super.close(); + uniqueConnections.delete(current); + } + + for (const connection of uniqueConnections) { + const closableConnection = connection as DriverConnection & { close?: () => Promise }; + await closableConnection.close?.(); + } + + this.connections = { primary: null, replica: null }; + this.resetConnectionState(); + } + + protected override async performConnect(connectionName?: string): Promise { + const requestedConnectionChange = connectionName !== undefined; + const requestedName = (connectionName ?? "replica") as string; + + if (requestedName !== "primary" && requestedName !== "replica") { + throw new TypeError("Invalid option to connect(), only primary or replica allowed."); + } + + const current = this.getWrappedDriverConnection(); + + if (current !== null && !requestedConnectionChange) { + return current; + } + + let selectedConnectionName = requestedName as NamedConnection; + let forcePrimaryAsReplica = false; + + if (this.getTransactionNestingLevel() > 0) { + selectedConnectionName = "primary"; + forcePrimaryAsReplica = true; + } + + const existing = this.connections[selectedConnectionName]; + if (existing !== null) { + this.setWrappedDriverConnection(existing); + + if (forcePrimaryAsReplica && !this.keepReplica) { + this.connections.replica = existing; + } + + return existing; + } + + if (selectedConnectionName === "primary") { + const primaryConnection = await this.connectTo("primary"); + this.connections.primary = primaryConnection; + this.setWrappedDriverConnection(primaryConnection); + + if (!this.keepReplica) { + this.connections.replica = primaryConnection; + } + + return primaryConnection; + } + + const replicaConnection = await this.connectTo("replica"); + this.connections.replica = replicaConnection; + this.setWrappedDriverConnection(replicaConnection); + + return replicaConnection; + } + + protected async connectTo(connectionName: NamedConnection): Promise { + const params = this.getParams() as PrimaryReadReplicaConnectionParams; + const primaryParams = params.primary; + + const connectionParams = + connectionName === "primary" + ? primaryParams + : this.chooseReplicaConnectionParameters(primaryParams, params.replica); + + try { + return await this.getDriver().connect(connectionParams); + } catch (error) { + throw this.convertException(error, "connect"); + } + } + + protected chooseReplicaConnectionParameters( + primary: OverrideParams, + replicas: OverrideParams[], + ): OverrideParams { + const replica = replicas[Math.floor(Math.random() * replicas.length)] ?? replicas[0]; + const params = { ...(replica ?? {}) }; + + if (params.charset === undefined && primary.charset !== undefined) { + params.charset = primary.charset; + } + + return params; + } +} diff --git a/src/driver-manager.ts b/src/driver-manager.ts index 5c0f512..d58e062 100644 --- a/src/driver-manager.ts +++ b/src/driver-manager.ts @@ -1,11 +1,16 @@ import { Configuration } from "./configuration"; import { Connection } from "./connection"; +import { + PrimaryReadReplicaConnection, + type PrimaryReadReplicaConnectionParams, +} from "./connections/primary-read-replica-connection"; import type { Driver } from "./driver"; import { MSSQLDriver } from "./driver/mssql/driver"; import { MySQL2Driver } from "./driver/mysql2/driver"; import { PgDriver } from "./driver/pg/driver"; import { SQLite3Driver } from "./driver/sqlite3/driver"; -import { DriverRequiredException, UnknownDriverException } from "./exception/index"; +import { DriverRequired } from "./exception/driver-required"; +import { UnknownDriver } from "./exception/unknown-driver"; export type DriverName = "mysql2" | "mssql" | "pg" | "sqlite3"; @@ -37,6 +42,20 @@ export class DriverManager { return new Connection(params, wrappedDriver, configuration); } + public static getPrimaryReadReplicaConnection( + params: PrimaryReadReplicaConnectionParams, + configuration: Configuration = new Configuration(), + ): PrimaryReadReplicaConnection { + const driver = DriverManager.createDriver(params); + + let wrappedDriver = driver; + for (const middleware of configuration.getMiddlewares()) { + wrappedDriver = middleware.wrap(wrappedDriver); + } + + return new PrimaryReadReplicaConnection(params, wrappedDriver, configuration); + } + public static getAvailableDrivers(): DriverName[] { return Object.keys(DriverManager.DRIVER_MAP) as DriverName[]; } @@ -51,12 +70,12 @@ export class DriverManager { } if (params.driver === undefined) { - throw new DriverRequiredException(); + throw DriverRequired.new(); } const DriverClass = DriverManager.DRIVER_MAP[params.driver]; if (DriverClass === undefined) { - throw new UnknownDriverException(params.driver, Object.keys(DriverManager.DRIVER_MAP)); + throw UnknownDriver.new(params.driver, Object.keys(DriverManager.DRIVER_MAP)); } return new DriverClass(); diff --git a/src/driver.ts b/src/driver.ts index ef36796..3f63288 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -1,46 +1,31 @@ import type { ExceptionConverter } from "./driver/api/exception-converter"; +import type { Connection as DriverConnection } from "./driver/connection"; +import type { Middleware as DriverMiddleware } from "./driver/middleware"; import type { AbstractPlatform } from "./platforms/abstract-platform"; import type { ServerVersionProvider } from "./server-version-provider"; -import type { CompiledQuery } from "./types"; - -export enum ParameterBindingStyle { - POSITIONAL = "positional", - NAMED = "named", -} - -export interface DriverQueryResult { - rows: Array>; - columns?: string[]; - rowCount?: number; -} - -export interface DriverExecutionResult { - affectedRows: number; - insertId?: number | string | null; -} - -export interface DriverConnection extends ServerVersionProvider { - executeQuery(query: CompiledQuery): Promise; - executeStatement(query: CompiledQuery): Promise; - beginTransaction(): Promise; - commit(): Promise; - rollBack(): Promise; - createSavepoint?(name: string): Promise; - releaseSavepoint?(name: string): Promise; - rollbackSavepoint?(name: string): Promise; - quote?(value: string): string; - close(): Promise; - getNativeConnection(): unknown; -} export interface Driver { - readonly name: string; - readonly bindingStyle: ParameterBindingStyle; + /** + * Attempts to create a connection with the database. + * + * @throws Exception + */ connect(params: Record): Promise; + + /** + * Gets the ExceptionConverter that can be used to convert driver-level exceptions into DBAL exceptions. + */ getExceptionConverter(): ExceptionConverter; + + /** + * Gets the DatabasePlatform instance that provides all the metadata about + * the platform this driver connects to. + * + * @return AbstractPlatform The database platform. + * + * @throws PlatformException + */ getDatabasePlatform(versionProvider: ServerVersionProvider): AbstractPlatform; } -export interface DriverMiddleware { - wrap(driver: Driver): Driver; -} +export type { DriverConnection, DriverMiddleware }; diff --git a/src/driver/abstract-db2-driver.ts b/src/driver/abstract-db2-driver.ts new file mode 100644 index 0000000..3b6be9a --- /dev/null +++ b/src/driver/abstract-db2-driver.ts @@ -0,0 +1,17 @@ +import type { Driver } from "../driver"; +import { DB2Platform } from "../platforms/db2-platform"; +import type { ServerVersionProvider } from "../server-version-provider"; +import type { ExceptionConverter as DriverExceptionConverter } from "./api/exception-converter"; +import { ExceptionConverter } from "./api/ibmdb2/exception-converter"; + +export abstract class AbstractDB2Driver implements Driver { + public getDatabasePlatform(_versionProvider: ServerVersionProvider): DB2Platform { + return new DB2Platform(); + } + + public getExceptionConverter(): DriverExceptionConverter { + return new ExceptionConverter(); + } + + public abstract connect(params: Record): ReturnType; +} diff --git a/src/driver/abstract-exception.ts b/src/driver/abstract-exception.ts new file mode 100644 index 0000000..9a24f57 --- /dev/null +++ b/src/driver/abstract-exception.ts @@ -0,0 +1,27 @@ +import type { Exception } from "./exception"; + +export abstract class AbstractException extends Error implements Exception { + public readonly code: number; + public readonly sqlState: string | null; + + constructor(message: string, sqlState: string | null = null, code = 0, cause?: unknown) { + super(message); + this.name = new.target.name; + this.sqlState = sqlState; + this.code = code; + Object.setPrototypeOf(this, new.target.prototype); + + if (cause !== undefined) { + Object.defineProperty(this, "cause", { + configurable: true, + enumerable: false, + value: cause, + writable: true, + }); + } + } + + public getSQLState(): string | null { + return this.sqlState; + } +} diff --git a/src/driver/abstract-mysql-driver.ts b/src/driver/abstract-mysql-driver.ts new file mode 100644 index 0000000..838e1e3 --- /dev/null +++ b/src/driver/abstract-mysql-driver.ts @@ -0,0 +1,83 @@ +import { coerce, gte } from "semver"; + +import type { Driver } from "../driver"; +import { AbstractMySQLPlatform } from "../platforms/abstract-mysql-platform"; +import { InvalidPlatformVersion } from "../platforms/exception/invalid-platform-version"; +import { MariaDBPlatform } from "../platforms/mariadb-platform"; +import { MariaDB1010Platform } from "../platforms/mariadb1010-platform"; +import { MariaDB1052Platform } from "../platforms/mariadb1052-platform"; +import { MariaDB1060Platform } from "../platforms/mariadb1060-platform"; +import { MariaDB110700Platform } from "../platforms/mariadb110700-platform"; +import { MySQLPlatform } from "../platforms/mysql-platform"; +import { MySQL80Platform } from "../platforms/mysql80-platform"; +import { MySQL84Platform } from "../platforms/mysql84-platform"; +import type { ServerVersionProvider } from "../server-version-provider"; +import type { ExceptionConverter as DriverExceptionConverter } from "./api/exception-converter"; +import { ExceptionConverter } from "./api/mysql/exception-converter"; + +export abstract class AbstractMySQLDriver implements Driver { + public getDatabasePlatform(versionProvider: ServerVersionProvider): AbstractMySQLPlatform { + const version = versionProvider.getServerVersion(); + + if (typeof version !== "string") { + return new MySQLPlatform(); + } + + if (version.toLowerCase().includes("mariadb")) { + const mariaDbVersion = this.getMariaDbMysqlVersionNumber(version); + if (gte(mariaDbVersion, "11.7.0")) { + return new MariaDB110700Platform(); + } + + if (gte(mariaDbVersion, "10.10.0")) { + return new MariaDB1010Platform(); + } + + if (gte(mariaDbVersion, "10.6.0")) { + return new MariaDB1060Platform(); + } + + if (gte(mariaDbVersion, "10.5.2")) { + return new MariaDB1052Platform(); + } + + return new MariaDBPlatform(); + } + + const mysqlVersion = coerce(version)?.version; + if (mysqlVersion === undefined) { + throw InvalidPlatformVersion.new(version, ".."); + } + + if (gte(mysqlVersion, "8.4.0")) { + return new MySQL84Platform(); + } + + if (gte(mysqlVersion, "8.0.0")) { + return new MySQL80Platform(); + } + + return new MySQLPlatform(); + } + + public getExceptionConverter(): DriverExceptionConverter { + return new ExceptionConverter(); + } + + public abstract connect(params: Record): ReturnType; + + private getMariaDbMysqlVersionNumber(versionString: string): string { + const match = /^(?:5\.5\.5-)?(?:mariadb-)?(?\d+)\.(?\d+)\.(?\d+)/i.exec( + versionString, + ); + + if (match?.groups === undefined) { + throw InvalidPlatformVersion.new( + versionString, + "^(?:5.5.5-)?(mariadb-)?..", + ); + } + + return `${match.groups.major}.${match.groups.minor}.${match.groups.patch}`; + } +} diff --git a/src/driver/abstract-oracle-driver/easy-connect-string.ts b/src/driver/abstract-oracle-driver/easy-connect-string.ts new file mode 100644 index 0000000..bf9682a --- /dev/null +++ b/src/driver/abstract-oracle-driver/easy-connect-string.ts @@ -0,0 +1,169 @@ +type EasyConnectParams = Record | unknown[]; + +/** + * Represents an Oracle Easy Connect string. + * + * @link https://docs.oracle.com/database/121/NETAG/naming.htm + */ +export class EasyConnectString { + private constructor(private readonly value: string) {} + + public toString(): string { + return this.value; + } + + public static fromArray(params: Record): EasyConnectString { + return new EasyConnectString(EasyConnectString.renderParams(params)); + } + + public static fromConnectionParameters(params: Record): EasyConnectString { + const connectString = params.connectstring; + if (EasyConnectString.isSet(connectString)) { + return new EasyConnectString(EasyConnectString.phpStringCast(connectString)); + } + + const host = params.host; + if (!EasyConnectString.isSet(host)) { + const dbname = params.dbname; + + return new EasyConnectString( + EasyConnectString.isSet(dbname) ? EasyConnectString.phpStringCast(dbname) : "", + ); + } + + const connectData: Record = {}; + + if (EasyConnectString.isSet(params.service)) { + EasyConnectString.emitServiceParameterDeprecation(); + } + + if (EasyConnectString.isSet(params.servicename) || EasyConnectString.isSet(params.dbname)) { + let serviceKey = "SID"; + + if (EasyConnectString.isSet(params.service) || EasyConnectString.isSet(params.servicename)) { + serviceKey = "SERVICE_NAME"; + } + + const serviceName = EasyConnectString.isSet(params.servicename) + ? params.servicename + : params.dbname; + + connectData[serviceKey] = serviceName; + } + + if (EasyConnectString.isSet(params.instancename)) { + connectData.INSTANCE_NAME = params.instancename; + } + + if (!EasyConnectString.isPhpEmpty(params.pooled)) { + connectData.SERVER = "POOLED"; + } + + const driverOptions = EasyConnectString.asRecord(params.driverOptions); + const protocol = EasyConnectString.isSet(driverOptions?.protocol) + ? driverOptions?.protocol + : "TCP"; + + const port = EasyConnectString.isSet(params.port) ? params.port : 1521; + const address: Record = {}; + address.PROTOCOL = protocol; + address.HOST = host; + address.PORT = port; + + return EasyConnectString.fromArray({ + DESCRIPTION: { + ADDRESS: address, + CONNECT_DATA: connectData, + }, + }); + } + + private static renderParams(params: EasyConnectParams): string { + const chunks: string[] = []; + + for (const [key, rawValue] of Object.entries(params)) { + const renderedValue = EasyConnectString.renderValue(rawValue); + + if (renderedValue === "") { + continue; + } + + chunks.push(`(${key}=${renderedValue})`); + } + + return chunks.join(""); + } + + private static renderValue(value: unknown): string { + if (Array.isArray(value) || EasyConnectString.isPlainObject(value)) { + return EasyConnectString.renderParams(value as EasyConnectParams); + } + + return EasyConnectString.phpStringCast(value); + } + + private static emitServiceParameterDeprecation(): void { + process.emitWarning( + 'Using the "service" parameter to indicate that the value of the "dbname" parameter is the service name is deprecated. Use the "servicename" parameter instead.', + "DeprecationWarning", + ); + } + + private static asRecord(value: unknown): Record | null { + if (!EasyConnectString.isPlainObject(value)) { + return null; + } + + return value; + } + + private static isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + + return prototype === Object.prototype || prototype === null; + } + + private static isSet(value: unknown): boolean { + return value !== undefined && value !== null; + } + + private static isPhpEmpty(value: unknown): boolean { + if (value === undefined || value === null) { + return true; + } + + if (value === false || value === 0 || value === "" || value === "0") { + return true; + } + + if (Array.isArray(value)) { + return value.length === 0; + } + + if (EasyConnectString.isPlainObject(value)) { + return Object.keys(value).length === 0; + } + + return false; + } + + private static phpStringCast(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + + if (value === true) { + return "1"; + } + + if (value === false) { + return ""; + } + + return String(value); + } +} diff --git a/src/driver/abstract-postgre-sql-driver.ts b/src/driver/abstract-postgre-sql-driver.ts new file mode 100644 index 0000000..6acf0e6 --- /dev/null +++ b/src/driver/abstract-postgre-sql-driver.ts @@ -0,0 +1,38 @@ +import type { Driver } from "../driver"; +import { InvalidPlatformVersion } from "../platforms/exception/invalid-platform-version"; +import { PostgreSQLPlatform } from "../platforms/postgre-sql-platform"; +import { PostgreSQL120Platform } from "../platforms/postgre-sql120-platform"; +import type { ServerVersionProvider } from "../server-version-provider"; +import type { ExceptionConverter as DriverExceptionConverter } from "./api/exception-converter"; +import { ExceptionConverter } from "./api/pgsql/exception-converter"; + +export abstract class AbstractPostgreSQLDriver implements Driver { + public getDatabasePlatform(versionProvider: ServerVersionProvider): PostgreSQLPlatform { + const version = versionProvider.getServerVersion(); + if (typeof version !== "string") { + return new PostgreSQLPlatform(); + } + + const match = /^(?\d+)(?:\.(?\d+)(?:\.(?\d+))?)?/.exec(version); + if (match?.groups === undefined) { + throw InvalidPlatformVersion.new(version, ".."); + } + + const major = Number.parseInt(match.groups.major ?? "0", 10); + if (Number.isNaN(major)) { + throw InvalidPlatformVersion.new(version, ".."); + } + + if (major >= 12) { + return new PostgreSQL120Platform(); + } + + return new PostgreSQLPlatform(); + } + + public getExceptionConverter(): DriverExceptionConverter { + return new ExceptionConverter(); + } + + public abstract connect(params: Record): ReturnType; +} diff --git a/src/driver/abstract-sql-server-driver.ts b/src/driver/abstract-sql-server-driver.ts new file mode 100644 index 0000000..0548721 --- /dev/null +++ b/src/driver/abstract-sql-server-driver.ts @@ -0,0 +1,17 @@ +import type { Driver } from "../driver"; +import { SQLServerPlatform } from "../platforms/sql-server-platform"; +import type { ServerVersionProvider } from "../server-version-provider"; +import type { ExceptionConverter as DriverExceptionConverter } from "./api/exception-converter"; +import { ExceptionConverter } from "./api/sql-server/exception-converter"; + +export abstract class AbstractSQLServerDriver implements Driver { + public getDatabasePlatform(_versionProvider: ServerVersionProvider): SQLServerPlatform { + return new SQLServerPlatform(); + } + + public getExceptionConverter(): DriverExceptionConverter { + return new ExceptionConverter(); + } + + public abstract connect(params: Record): ReturnType; +} diff --git a/src/driver/abstract-sql-server-driver/exception/port-without-host.ts b/src/driver/abstract-sql-server-driver/exception/port-without-host.ts new file mode 100644 index 0000000..e0e4a4f --- /dev/null +++ b/src/driver/abstract-sql-server-driver/exception/port-without-host.ts @@ -0,0 +1,10 @@ +import { AbstractException } from "../../abstract-exception"; + +/** + * @internal + */ +export class PortWithoutHost extends AbstractException { + public static new(): PortWithoutHost { + return new PortWithoutHost("Connection port specified without the host"); + } +} diff --git a/src/driver/abstract-sqlite-driver.ts b/src/driver/abstract-sqlite-driver.ts new file mode 100644 index 0000000..a78e246 --- /dev/null +++ b/src/driver/abstract-sqlite-driver.ts @@ -0,0 +1,17 @@ +import type { Driver } from "../driver"; +import { SQLitePlatform } from "../platforms/sqlite-platform"; +import type { ServerVersionProvider } from "../server-version-provider"; +import type { ExceptionConverter as DriverExceptionConverter } from "./api/exception-converter"; +import { ExceptionConverter } from "./api/sqlite/exception-converter"; + +export abstract class AbstractSQLiteDriver implements Driver { + public getDatabasePlatform(_versionProvider: ServerVersionProvider): SQLitePlatform { + return new SQLitePlatform(); + } + + public getExceptionConverter(): DriverExceptionConverter { + return new ExceptionConverter(); + } + + public abstract connect(params: Record): ReturnType; +} diff --git a/src/driver/abstract-sqlite-driver/middleware/enable-foreign-keys.ts b/src/driver/abstract-sqlite-driver/middleware/enable-foreign-keys.ts new file mode 100644 index 0000000..6eb901a --- /dev/null +++ b/src/driver/abstract-sqlite-driver/middleware/enable-foreign-keys.ts @@ -0,0 +1,19 @@ +import type { Driver, DriverConnection } from "../../../driver"; +import type { Middleware } from "../../../driver/middleware"; +import { AbstractDriverMiddleware } from "../../../driver/middleware/abstract-driver-middleware"; + +const ENABLE_FOREIGN_KEYS_PRAGMA = "PRAGMA foreign_keys=ON"; + +class ForeignKeysEnabledDriver extends AbstractDriverMiddleware { + public override async connect(params: Record): Promise { + const connection = await super.connect(params); + await connection.exec(ENABLE_FOREIGN_KEYS_PRAGMA); + return connection; + } +} + +export class EnableForeignKeys implements Middleware { + public wrap(driver: Driver): Driver { + return new ForeignKeysEnabledDriver(driver); + } +} diff --git a/src/driver/api/ibmdb2/exception-converter.ts b/src/driver/api/ibmdb2/exception-converter.ts new file mode 100644 index 0000000..1f46004 --- /dev/null +++ b/src/driver/api/ibmdb2/exception-converter.ts @@ -0,0 +1,138 @@ +import { ConnectionException } from "../../../exception/connection-exception"; +import { DriverException, type DriverExceptionDetails } from "../../../exception/driver-exception"; +import { ForeignKeyConstraintViolationException } from "../../../exception/foreign-key-constraint-violation-exception"; +import { InvalidFieldNameException } from "../../../exception/invalid-field-name-exception"; +import { NonUniqueFieldNameException } from "../../../exception/non-unique-field-name-exception"; +import { NotNullConstraintViolationException } from "../../../exception/not-null-constraint-violation-exception"; +import { SyntaxErrorException } from "../../../exception/syntax-error-exception"; +import { TableExistsException } from "../../../exception/table-exists-exception"; +import { TableNotFoundException } from "../../../exception/table-not-found-exception"; +import { UniqueConstraintViolationException } from "../../../exception/unique-constraint-violation-exception"; +import type { + ExceptionConverterContext, + ExceptionConverter as ExceptionConverterContract, +} from "../exception-converter"; + +const FOREIGN_KEY_CONSTRAINT_CODES = new Set([-530, -531, -532, -20356]); +const CONNECTION_CODES = new Set([-1336, -30082]); +export class ExceptionConverter implements ExceptionConverterContract { + public convert(error: unknown, context: ExceptionConverterContext): DriverException { + const details = this.createDetails(error, context); + + if (details.code === -104) { + return new SyntaxErrorException(details.message, details); + } + + if (details.code === -407) { + return new NotNullConstraintViolationException(details.message, details); + } + + if (details.code === -203) { + return new NonUniqueFieldNameException(details.message, details); + } + + if (details.code === -204) { + return new TableNotFoundException(details.message, details); + } + + if (details.code === -206) { + return new InvalidFieldNameException(details.message, details); + } + + if (details.code === -601) { + return new TableExistsException(details.message, details); + } + + if (details.code === -803) { + return new UniqueConstraintViolationException(details.message, details); + } + + if (typeof details.code === "number" && FOREIGN_KEY_CONSTRAINT_CODES.has(details.code)) { + return new ForeignKeyConstraintViolationException(details.message, details); + } + + if (typeof details.code === "number" && CONNECTION_CODES.has(details.code)) { + return new ConnectionException(details.message, details); + } + + return new DriverException(details.message, details); + } + + private createDetails( + error: unknown, + context: ExceptionConverterContext, + ): DriverExceptionDetails & { message: string } { + const record = this.asRecord(error); + const message = this.extractMessage(error); + + return { + cause: error, + code: this.extractCode(record), + driverName: "ibmdb2", + message, + operation: context.operation, + parameters: context.query?.parameters, + sql: context.query?.sql, + sqlState: this.extractSqlState(record, message), + }; + } + + private extractCode(record: Record): number | string | undefined { + const candidates: unknown[] = [record.sqlcode, record.sqlCode, record.code]; + + for (const candidate of candidates) { + if (typeof candidate === "number") { + return candidate; + } + + if (typeof candidate === "string") { + const trimmed = candidate.trim(); + if (trimmed.length === 0) { + continue; + } + + const parsed = Number(trimmed); + if (Number.isInteger(parsed)) { + return parsed; + } + + return candidate; + } + } + + return undefined; + } + + private extractSqlState(record: Record, message: string): string | undefined { + const candidates: unknown[] = [record.sqlstate, record.sqlState, record.state]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.length > 0) { + return candidate; + } + } + + const match = /SQLSTATE[=\s:]+([0-9A-Z]{5})/i.exec(message); + if (match?.[1] !== undefined) { + return match[1].toUpperCase(); + } + + return undefined; + } + + private extractMessage(error: unknown): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return "ibmdb2 driver error."; + } + + private asRecord(value: unknown): Record { + if (value !== null && typeof value === "object") { + return value as Record; + } + + return {}; + } +} diff --git a/src/driver/api/mysql/exception-converter.ts b/src/driver/api/mysql/exception-converter.ts index fb34145..634e457 100644 --- a/src/driver/api/mysql/exception-converter.ts +++ b/src/driver/api/mysql/exception-converter.ts @@ -1,13 +1,10 @@ -import { - ConnectionException, - DeadlockException, - DriverException, - type DriverExceptionDetails, - ForeignKeyConstraintViolationException, - NotNullConstraintViolationException, - SqlSyntaxException, - UniqueConstraintViolationException, -} from "../../../exception/index"; +import { ConnectionException } from "../../../exception/connection-exception"; +import { DeadlockException } from "../../../exception/deadlock-exception"; +import { DriverException, type DriverExceptionDetails } from "../../../exception/driver-exception"; +import { ForeignKeyConstraintViolationException } from "../../../exception/foreign-key-constraint-violation-exception"; +import { NotNullConstraintViolationException } from "../../../exception/not-null-constraint-violation-exception"; +import { SyntaxErrorException } from "../../../exception/syntax-error-exception"; +import { UniqueConstraintViolationException } from "../../../exception/unique-constraint-violation-exception"; import type { ExceptionConverterContext, ExceptionConverter as ExceptionConverterContract, @@ -53,7 +50,7 @@ export class ExceptionConverter implements ExceptionConverterContract { } if (typeof details.code === "number" && SQL_SYNTAX_CODES.has(details.code)) { - return new SqlSyntaxException(details.message, details); + return new SyntaxErrorException(details.message, details); } if (this.isConnectionError(details.code)) { diff --git a/src/driver/api/oci/exception-converter.ts b/src/driver/api/oci/exception-converter.ts new file mode 100644 index 0000000..f4ad40b --- /dev/null +++ b/src/driver/api/oci/exception-converter.ts @@ -0,0 +1,234 @@ +import { ConnectionException } from "../../../exception/connection-exception"; +import { DatabaseDoesNotExist } from "../../../exception/database-does-not-exist"; +import { DatabaseObjectNotFoundException } from "../../../exception/database-object-not-found-exception"; +import { DriverException, type DriverExceptionDetails } from "../../../exception/driver-exception"; +import { ForeignKeyConstraintViolationException } from "../../../exception/foreign-key-constraint-violation-exception"; +import { InvalidFieldNameException } from "../../../exception/invalid-field-name-exception"; +import { NonUniqueFieldNameException } from "../../../exception/non-unique-field-name-exception"; +import { NotNullConstraintViolationException } from "../../../exception/not-null-constraint-violation-exception"; +import { SyntaxErrorException } from "../../../exception/syntax-error-exception"; +import { TableExistsException } from "../../../exception/table-exists-exception"; +import { TableNotFoundException } from "../../../exception/table-not-found-exception"; +import { UniqueConstraintViolationException } from "../../../exception/unique-constraint-violation-exception"; +import type { + ExceptionConverterContext, + ExceptionConverter as ExceptionConverterContract, +} from "../exception-converter"; + +const UNIQUE_CONSTRAINT_CODES = new Set([1, 2299, 38911]); +const CONNECTION_CODES = new Set([1017, 12545]); +const FOREIGN_KEY_CONSTRAINT_CODES = new Set([2266, 2291, 2292]); +const DATABASE_OBJECT_NOT_FOUND_CODES = new Set([2289, 2443, 4080]); + +export class ExceptionConverter implements ExceptionConverterContract { + public convert(error: unknown, context: ExceptionConverterContext): DriverException { + const details = this.createDetails(error, context); + + if (typeof details.code === "number" && UNIQUE_CONSTRAINT_CODES.has(details.code)) { + return new UniqueConstraintViolationException(details.message, details); + } + + if (details.code === 923) { + return new SyntaxErrorException(details.message, details); + } + + if (details.code === 904) { + return new InvalidFieldNameException(details.message, details); + } + + if (details.code === 918) { + return new NonUniqueFieldNameException(details.message, details); + } + + if (details.code === 942) { + return new TableNotFoundException(details.message, details); + } + + if (details.code === 955) { + return new TableExistsException(details.message, details); + } + + if (details.code === 1400) { + return new NotNullConstraintViolationException(details.message, details); + } + + if (details.code === 1918) { + return new DatabaseDoesNotExist(details.message, details); + } + + if (typeof details.code === "number" && CONNECTION_CODES.has(details.code)) { + return new ConnectionException(details.message, details); + } + + if (details.code === 2091) { + const rolledBackReason = this.convertRolledBackCause(error, context); + if (rolledBackReason !== null) { + return rolledBackReason; + } + + return new DriverException(details.message, details); + } + + if (typeof details.code === "number" && FOREIGN_KEY_CONSTRAINT_CODES.has(details.code)) { + return new ForeignKeyConstraintViolationException(details.message, details); + } + + if (typeof details.code === "number" && DATABASE_OBJECT_NOT_FOUND_CODES.has(details.code)) { + return new DatabaseObjectNotFoundException(details.message, details); + } + + return new DriverException(details.message, details); + } + + private convertRolledBackCause( + error: unknown, + context: ExceptionConverterContext, + ): DriverException | null { + const message = this.extractMessage(error); + const lines = message.split(/\r?\n/, 2); + + if (lines.length < 2) { + return null; + } + + const causeMessage = lines[1]; + if (causeMessage === undefined || causeMessage.length === 0) { + return null; + } + + const causeCode = this.parseOracleCodeFromString(causeMessage); + const record: Record = { + code: causeCode ?? this.extractCode(this.asRecord(error)), + message: causeMessage, + sqlState: this.extractSqlState(this.asRecord(error), message), + }; + + const nestedError = this.createNestedError(causeMessage, record); + + return this.convert(nestedError, context); + } + + private createNestedError( + causeMessage: string, + record: Record, + ): Error & Record { + const nested = new Error(causeMessage) as Error & Record; + + for (const [key, value] of Object.entries(record)) { + nested[key] = value; + } + + return nested; + } + + private createDetails( + error: unknown, + context: ExceptionConverterContext, + ): DriverExceptionDetails & { message: string } { + const record = this.asRecord(error); + const message = this.extractMessage(error); + + return { + cause: error, + code: this.extractCode(record), + driverName: "oci8", + message, + operation: context.operation, + parameters: context.query?.parameters, + sql: context.query?.sql, + sqlState: this.extractSqlState(record, message), + }; + } + + private extractCode(record: Record): number | string | undefined { + const candidates: unknown[] = [record.errorNum, record.errornum, record.code, record.errno]; + + for (const candidate of candidates) { + if (typeof candidate === "number") { + return candidate; + } + + if (typeof candidate === "string") { + const parsedOraCode = this.parseOracleCodeFromString(candidate); + if (parsedOraCode !== undefined) { + return parsedOraCode; + } + + const trimmed = candidate.trim(); + if (trimmed.length === 0) { + continue; + } + + const parsed = Number(trimmed); + if (Number.isInteger(parsed)) { + return parsed; + } + + return candidate; + } + } + + if (typeof record.message === "string") { + const parsedFromMessage = this.parseOracleCodeFromString(record.message); + if (parsedFromMessage !== undefined) { + return parsedFromMessage; + } + } + + return undefined; + } + + private extractSqlState(record: Record, message: string): string | undefined { + const candidates: unknown[] = [record.sqlState, record.sqlstate, record.state]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.length > 0) { + return candidate; + } + } + + const bracketed = /SQLSTATE\[([0-9A-Z]{5})\]/i.exec(message); + if (bracketed?.[1] !== undefined) { + return bracketed[1].toUpperCase(); + } + + const inline = /SQLSTATE[=\s:]+([0-9A-Z]{5})/i.exec(message); + if (inline?.[1] !== undefined) { + return inline[1].toUpperCase(); + } + + return undefined; + } + + private extractMessage(error: unknown): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + const record = this.asRecord(error); + if (typeof record.message === "string" && record.message.length > 0) { + return record.message; + } + + return "oci driver error."; + } + + private parseOracleCodeFromString(value: string): number | undefined { + const match = /ORA-(\d{3,5})/i.exec(value); + if (match?.[1] === undefined) { + return undefined; + } + + const parsed = Number(match[1]); + + return Number.isInteger(parsed) ? parsed : undefined; + } + + private asRecord(value: unknown): Record { + if (value !== null && typeof value === "object") { + return value as Record; + } + + return {}; + } +} diff --git a/src/driver/api/pgsql/exception-converter.ts b/src/driver/api/pgsql/exception-converter.ts index 10ae4e7..5f16048 100644 --- a/src/driver/api/pgsql/exception-converter.ts +++ b/src/driver/api/pgsql/exception-converter.ts @@ -1,13 +1,10 @@ -import { - ConnectionException, - DeadlockException, - DriverException, - type DriverExceptionDetails, - ForeignKeyConstraintViolationException, - NotNullConstraintViolationException, - SqlSyntaxException, - UniqueConstraintViolationException, -} from "../../../exception/index"; +import { ConnectionException } from "../../../exception/connection-exception"; +import { DeadlockException } from "../../../exception/deadlock-exception"; +import { DriverException, type DriverExceptionDetails } from "../../../exception/driver-exception"; +import { ForeignKeyConstraintViolationException } from "../../../exception/foreign-key-constraint-violation-exception"; +import { NotNullConstraintViolationException } from "../../../exception/not-null-constraint-violation-exception"; +import { SyntaxErrorException } from "../../../exception/syntax-error-exception"; +import { UniqueConstraintViolationException } from "../../../exception/unique-constraint-violation-exception"; import type { ExceptionConverterContext, ExceptionConverter as ExceptionConverterContract, @@ -40,7 +37,7 @@ export class ExceptionConverter implements ExceptionConverterContract { } if (details.sqlState !== undefined && SYNTAX_SQLSTATES.has(details.sqlState)) { - return new SqlSyntaxException(details.message, details); + return new SyntaxErrorException(details.message, details); } if (this.isConnectionError(details)) { diff --git a/src/driver/api/sqlsrv/exception-converter.ts b/src/driver/api/sql-server/exception-converter.ts similarity index 84% rename from src/driver/api/sqlsrv/exception-converter.ts rename to src/driver/api/sql-server/exception-converter.ts index 537e4ce..dd7ab5d 100644 --- a/src/driver/api/sqlsrv/exception-converter.ts +++ b/src/driver/api/sql-server/exception-converter.ts @@ -1,13 +1,10 @@ -import { - ConnectionException, - DeadlockException, - DriverException, - type DriverExceptionDetails, - ForeignKeyConstraintViolationException, - NotNullConstraintViolationException, - SqlSyntaxException, - UniqueConstraintViolationException, -} from "../../../exception/index"; +import { ConnectionException } from "../../../exception/connection-exception"; +import { DeadlockException } from "../../../exception/deadlock-exception"; +import { DriverException, type DriverExceptionDetails } from "../../../exception/driver-exception"; +import { ForeignKeyConstraintViolationException } from "../../../exception/foreign-key-constraint-violation-exception"; +import { NotNullConstraintViolationException } from "../../../exception/not-null-constraint-violation-exception"; +import { SyntaxErrorException } from "../../../exception/syntax-error-exception"; +import { UniqueConstraintViolationException } from "../../../exception/unique-constraint-violation-exception"; import type { ExceptionConverterContext, ExceptionConverter as ExceptionConverterContract, @@ -50,7 +47,7 @@ export class ExceptionConverter implements ExceptionConverterContract { } if (typeof details.code === "number" && SQL_SYNTAX_CODES.has(details.code)) { - return new SqlSyntaxException(details.message, details); + return new SyntaxErrorException(details.message, details); } if (this.isConnectionError(details.code)) { diff --git a/src/driver/api/sqlite/exception-converter.ts b/src/driver/api/sqlite/exception-converter.ts index bd58268..098f447 100644 --- a/src/driver/api/sqlite/exception-converter.ts +++ b/src/driver/api/sqlite/exception-converter.ts @@ -1,13 +1,10 @@ -import { - ConnectionException, - DeadlockException, - DriverException, - type DriverExceptionDetails, - ForeignKeyConstraintViolationException, - NotNullConstraintViolationException, - SqlSyntaxException, - UniqueConstraintViolationException, -} from "../../../exception/index"; +import { ConnectionException } from "../../../exception/connection-exception"; +import { DeadlockException } from "../../../exception/deadlock-exception"; +import { DriverException, type DriverExceptionDetails } from "../../../exception/driver-exception"; +import { ForeignKeyConstraintViolationException } from "../../../exception/foreign-key-constraint-violation-exception"; +import { NotNullConstraintViolationException } from "../../../exception/not-null-constraint-violation-exception"; +import { SyntaxErrorException } from "../../../exception/syntax-error-exception"; +import { UniqueConstraintViolationException } from "../../../exception/unique-constraint-violation-exception"; import type { ExceptionConverterContext, ExceptionConverter as ExceptionConverterContract, @@ -43,7 +40,7 @@ export class ExceptionConverter implements ExceptionConverterContract { } if (code === "SQLITE_ERROR" && message.includes("syntax")) { - return new SqlSyntaxException(details.message, details); + return new SyntaxErrorException(details.message, details); } return new DriverException(details.message, details); diff --git a/src/driver/array-result.ts b/src/driver/array-result.ts new file mode 100644 index 0000000..42b9cb1 --- /dev/null +++ b/src/driver/array-result.ts @@ -0,0 +1,144 @@ +import type { Result as DriverResult } from "./result"; + +type AssociativeRow = Record; + +type ResultWithColumnName = DriverResult & { + getColumnName?: (index: number) => string; +}; + +export class ArrayResult implements DriverResult { + private rows: AssociativeRow[]; + private cursor = 0; + + constructor( + rows: AssociativeRow[], + private readonly columns: string[] = [], + private readonly affectedRowCount?: number | string, + ) { + this.rows = [...rows]; + } + + public fetchNumeric(): T[] | false { + const row = this.fetchAssociative(); + if (row === false) { + return false; + } + + return this.getColumnsFromRow(row).map((column) => row[column]) as T[]; + } + + public fetchAssociative(): T | false { + const row = this.rows[this.cursor]; + if (row === undefined) { + return false; + } + + this.cursor += 1; + return { ...row } as T; + } + + public fetchOne(): T | false { + const row = this.fetchNumeric(); + if (row === false) { + return false; + } + + const value = row[0]; + return value === undefined ? false : (value as T); + } + + public fetchAllNumeric(): T[][] { + const rows: T[][] = []; + let row = this.fetchNumeric(); + + while (row !== false) { + rows.push(row); + row = this.fetchNumeric(); + } + + return rows; + } + + public fetchAllAssociative(): T[] { + const rows: T[] = []; + let row = this.fetchAssociative(); + + while (row !== false) { + rows.push(row); + row = this.fetchAssociative(); + } + + return rows; + } + + public fetchFirstColumn(): T[] { + const values: T[] = []; + let value = this.fetchOne(); + + while (value !== false) { + values.push(value); + value = this.fetchOne(); + } + + return values; + } + + public rowCount(): number | string { + return this.affectedRowCount ?? this.rows.length; + } + + public columnCount(): number { + const row = this.rows[0]; + if (row === undefined) { + return this.columns.length; + } + + return this.getColumnsFromRow(row).length; + } + + public getColumnName(index: number): string { + const row = this.rows[0]; + const columns = row === undefined ? this.columns : this.getColumnsFromRow(row); + const name = columns[index]; + + if (name === undefined) { + throw new RangeError(`Column index ${index} is out of bounds.`); + } + + return name; + } + + public free(): void { + this.rows = []; + this.cursor = 0; + } + + public static fromDriverResult(result: DriverResult): ArrayResult { + const rows = result.fetchAllAssociative(); + const columns = ArrayResult.readColumns(result, rows[0]); + const rowCount = result.rowCount(); + result.free(); + + return new ArrayResult(rows, columns, rowCount); + } + + private static readColumns(result: DriverResult, firstRow: AssociativeRow | undefined): string[] { + const withColumnName = result as ResultWithColumnName; + + if (typeof withColumnName.getColumnName === "function") { + const columns: string[] = []; + const count = result.columnCount(); + for (let index = 0; index < count; index += 1) { + columns.push(withColumnName.getColumnName(index)); + } + + return columns; + } + + return firstRow === undefined ? [] : Object.keys(firstRow); + } + + private getColumnsFromRow(row: AssociativeRow): string[] { + return this.columns.length > 0 ? this.columns : Object.keys(row); + } +} diff --git a/src/driver/connection.ts b/src/driver/connection.ts new file mode 100644 index 0000000..4faa95c --- /dev/null +++ b/src/driver/connection.ts @@ -0,0 +1,15 @@ +import type { ServerVersionProvider } from "../server-version-provider"; +import type { Result } from "./result"; +import type { Statement } from "./statement"; + +export interface Connection extends ServerVersionProvider { + prepare(sql: string): Promise; + query(sql: string): Promise; + quote(value: string): string; + exec(sql: string): Promise; + lastInsertId(): Promise; + beginTransaction(): Promise; + commit(): Promise; + rollBack(): Promise; + getNativeConnection(): unknown; +} diff --git a/src/driver/exception-converter.ts b/src/driver/exception-converter.ts deleted file mode 100644 index b3f0214..0000000 --- a/src/driver/exception-converter.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type { - ExceptionConverter, - ExceptionConverterContext, -} from "./api/exception-converter"; diff --git a/src/driver/exception.ts b/src/driver/exception.ts new file mode 100644 index 0000000..59c8f77 --- /dev/null +++ b/src/driver/exception.ts @@ -0,0 +1,14 @@ +/** + * Contract for a driver exception. + * + * Driver exceptions provide the SQLSTATE of the driver + * and the driver specific error code at the time the error occurred. + */ +export interface Exception extends Error { + /** + * Returns the SQLSTATE the driver was in at the time the error occurred. + * + * Returns null if the driver does not provide a SQLSTATE for the error occurred. + */ + getSQLState(): string | null; +} diff --git a/src/driver/exception/identity-columns-not-supported.ts b/src/driver/exception/identity-columns-not-supported.ts new file mode 100644 index 0000000..ca3a6e5 --- /dev/null +++ b/src/driver/exception/identity-columns-not-supported.ts @@ -0,0 +1,12 @@ +import { AbstractException } from "../abstract-exception"; + +export class IdentityColumnsNotSupported extends AbstractException { + public static new(cause?: unknown): IdentityColumnsNotSupported { + return new IdentityColumnsNotSupported( + "The driver does not support identity columns.", + null, + 0, + cause, + ); + } +} diff --git a/src/driver/exception/no-identity-value.ts b/src/driver/exception/no-identity-value.ts new file mode 100644 index 0000000..7cb0d91 --- /dev/null +++ b/src/driver/exception/no-identity-value.ts @@ -0,0 +1,12 @@ +import { AbstractException } from "../abstract-exception"; + +export class NoIdentityValue extends AbstractException { + public static new(cause?: unknown): NoIdentityValue { + return new NoIdentityValue( + "No identity value was generated by the last statement.", + null, + 0, + cause, + ); + } +} diff --git a/src/driver/fetch-utils.ts b/src/driver/fetch-utils.ts new file mode 100644 index 0000000..5385dbb --- /dev/null +++ b/src/driver/fetch-utils.ts @@ -0,0 +1,35 @@ +import { Result } from "./result"; + +export class FetchUtils { + static fetchOne(result: Result): T | false { + const row = result.fetchNumeric(); + if (row === false) { + return false; + } + return row[0] as T; + } + + static fetchAllNumeric(result: Result): T[][] { + const rows: T[][] = []; + + let row = result.fetchNumeric(); + while (row !== false) { + rows.push(row as T[]); + row = result.fetchNumeric(); + } + + return rows; + } + + static fetchFirstColumn(result: Result): T[] { + const rows: T[] = []; + + let value = result.fetchOne(); + while (value !== false) { + rows.push(value); + value = result.fetchOne(); + } + + return rows; + } +} diff --git a/src/driver/index.ts b/src/driver/index.ts deleted file mode 100644 index a469b47..0000000 --- a/src/driver/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type { - Driver, - DriverConnection, - DriverExecutionResult, - DriverMiddleware, - DriverQueryResult, -} from "../driver"; -export { ParameterBindingStyle } from "../driver"; -export { ExceptionConverter as MySQLExceptionConverter } from "./api/mysql/exception-converter"; -export { ExceptionConverter as PgSQLExceptionConverter } from "./api/pgsql/exception-converter"; -export { ExceptionConverter as SQLiteExceptionConverter } from "./api/sqlite/exception-converter"; -export { ExceptionConverter as SQLSrvExceptionConverter } from "./api/sqlsrv/exception-converter"; -export type { ExceptionConverter, ExceptionConverterContext } from "./exception-converter"; -export { MSSQLConnection } from "./mssql/connection"; -export { MSSQLDriver } from "./mssql/driver"; -export { MSSQLExceptionConverter } from "./mssql/exception-converter"; -export type { - MSSQLConnectionParams, - MSSQLPoolLike, - MSSQLRequestLike, - MSSQLTransactionLike, -} from "./mssql/types"; -export { MySQL2Connection } from "./mysql2/connection"; -export { MySQL2Driver } from "./mysql2/driver"; -export { MySQL2ExceptionConverter } from "./mysql2/exception-converter"; -export type { - MySQL2ConnectionLike, - MySQL2ConnectionParams, - MySQL2ExecutorLike, - MySQL2PoolLike, -} from "./mysql2/types"; -export { PgConnection } from "./pg/connection"; -export { PgDriver } from "./pg/driver"; -export { PgExceptionConverter } from "./pg/exception-converter"; -export type { - PgConnectionParams, - PgFieldLike, - PgPoolClientLike, - PgPoolLike, - PgQueryResultLike, - PgQueryableLike, -} from "./pg/types"; -export { SQLite3Connection } from "./sqlite3/connection"; -export { SQLite3Driver } from "./sqlite3/driver"; -export { SQLite3ExceptionConverter } from "./sqlite3/exception-converter"; -export type { - SQLite3ConnectionParams, - SQLite3DatabaseLike, - SQLite3RunContextLike, -} from "./sqlite3/types"; diff --git a/src/driver/internal-parameter-binding-style.ts b/src/driver/internal-parameter-binding-style.ts new file mode 100644 index 0000000..f653c20 --- /dev/null +++ b/src/driver/internal-parameter-binding-style.ts @@ -0,0 +1,4 @@ +export enum ParameterBindingStyle { + POSITIONAL = "positional", + NAMED = "named", +} diff --git a/src/driver/internal-result-types.ts b/src/driver/internal-result-types.ts new file mode 100644 index 0000000..73fa2dd --- /dev/null +++ b/src/driver/internal-result-types.ts @@ -0,0 +1,10 @@ +export interface DriverQueryResult { + rows: Array>; + columns?: string[]; + rowCount?: number; +} + +export interface DriverExecutionResult { + affectedRows: number; + insertId?: number | string | null; +} diff --git a/src/driver/middleware.ts b/src/driver/middleware.ts new file mode 100644 index 0000000..7b7a16c --- /dev/null +++ b/src/driver/middleware.ts @@ -0,0 +1,5 @@ +import type { Driver } from "../driver"; + +export interface Middleware { + wrap(driver: Driver): Driver; +} diff --git a/src/driver/middleware/abstract-connection-middleware.ts b/src/driver/middleware/abstract-connection-middleware.ts new file mode 100644 index 0000000..e88320c --- /dev/null +++ b/src/driver/middleware/abstract-connection-middleware.ts @@ -0,0 +1,50 @@ +import type { Connection as DriverConnection } from "../connection"; + +export abstract class AbstractConnectionMiddleware implements DriverConnection { + constructor(private readonly wrappedConnection: DriverConnection) {} + + public prepare(sql: string): ReturnType { + return this.wrappedConnection.prepare(sql); + } + + public query(sql: string): ReturnType { + return this.wrappedConnection.query(sql); + } + + public quote(value: string): string { + return this.wrappedConnection.quote(value); + } + + public exec(sql: string): ReturnType { + return this.wrappedConnection.exec(sql); + } + + public lastInsertId(): ReturnType { + return this.wrappedConnection.lastInsertId(); + } + + public beginTransaction(): ReturnType { + return this.wrappedConnection.beginTransaction(); + } + + public commit(): ReturnType { + return this.wrappedConnection.commit(); + } + + public rollBack(): ReturnType { + return this.wrappedConnection.rollBack(); + } + + public getServerVersion(): ReturnType { + return this.wrappedConnection.getServerVersion(); + } + + public getNativeConnection(): unknown { + return this.wrappedConnection.getNativeConnection(); + } + + public async close(): Promise { + const closable = this.wrappedConnection as DriverConnection & { close?: () => Promise }; + await closable.close?.(); + } +} diff --git a/src/driver/middleware/abstract-driver-middleware.ts b/src/driver/middleware/abstract-driver-middleware.ts new file mode 100644 index 0000000..198c641 --- /dev/null +++ b/src/driver/middleware/abstract-driver-middleware.ts @@ -0,0 +1,21 @@ +import type { Driver } from "../../driver"; +import type { AbstractPlatform } from "../../platforms/abstract-platform"; +import type { ServerVersionProvider } from "../../server-version-provider"; +import type { ExceptionConverter } from "../api/exception-converter"; +import type { Connection as DriverConnection } from "../connection"; + +export abstract class AbstractDriverMiddleware implements Driver { + constructor(private readonly wrappedDriver: Driver) {} + + public async connect(params: Record): Promise { + return this.wrappedDriver.connect(params); + } + + public getDatabasePlatform(versionProvider: ServerVersionProvider): AbstractPlatform { + return this.wrappedDriver.getDatabasePlatform(versionProvider); + } + + public getExceptionConverter(): ExceptionConverter { + return this.wrappedDriver.getExceptionConverter(); + } +} diff --git a/src/driver/middleware/abstract-result-middleware.ts b/src/driver/middleware/abstract-result-middleware.ts new file mode 100644 index 0000000..382665e --- /dev/null +++ b/src/driver/middleware/abstract-result-middleware.ts @@ -0,0 +1,56 @@ +import type { Result as DriverResult } from "../result"; + +type ResultWithColumnName = DriverResult & { + getColumnName?: (index: number) => string; +}; + +export abstract class AbstractResultMiddleware implements DriverResult { + constructor(private readonly wrappedResult: DriverResult) {} + + public fetchNumeric(): T[] | false { + return this.wrappedResult.fetchNumeric(); + } + + public fetchAssociative = Record>(): + | T + | false { + return this.wrappedResult.fetchAssociative(); + } + + public fetchOne(): T | false { + return this.wrappedResult.fetchOne(); + } + + public fetchAllNumeric(): T[][] { + return this.wrappedResult.fetchAllNumeric(); + } + + public fetchAllAssociative = Record>(): T[] { + return this.wrappedResult.fetchAllAssociative(); + } + + public fetchFirstColumn(): T[] { + return this.wrappedResult.fetchFirstColumn(); + } + + public rowCount(): number | string { + return this.wrappedResult.rowCount(); + } + + public columnCount(): number { + return this.wrappedResult.columnCount(); + } + + public getColumnName(index: number): string { + const result = this.wrappedResult as ResultWithColumnName; + if (typeof result.getColumnName !== "function") { + throw new Error(`The driver result does not support accessing the column name.`); + } + + return result.getColumnName(index); + } + + public free(): void { + this.wrappedResult.free(); + } +} diff --git a/src/driver/middleware/abstract-statement-middleware.ts b/src/driver/middleware/abstract-statement-middleware.ts new file mode 100644 index 0000000..0db694c --- /dev/null +++ b/src/driver/middleware/abstract-statement-middleware.ts @@ -0,0 +1,15 @@ +import { ParameterType } from "../../parameter-type"; +import type { Result } from "../result"; +import type { Statement as DriverStatement } from "../statement"; + +export abstract class AbstractStatementMiddleware implements DriverStatement { + constructor(private readonly wrappedStatement: DriverStatement) {} + + public bindValue(param: string | number, value: unknown, type?: ParameterType): void { + this.wrappedStatement.bindValue(param, value, type); + } + + public execute(): Promise { + return this.wrappedStatement.execute(); + } +} diff --git a/src/driver/mssql/connection.ts b/src/driver/mssql/connection.ts index a1aa0a4..625edd5 100644 --- a/src/driver/mssql/connection.ts +++ b/src/driver/mssql/connection.ts @@ -1,6 +1,10 @@ -import type { DriverConnection, DriverExecutionResult, DriverQueryResult } from "../../driver"; -import { DbalException, InvalidParameterException } from "../../exception/index"; -import type { CompiledQuery } from "../../types"; +import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; +import { ArrayResult } from "../array-result"; +import type { Connection as DriverConnection } from "../connection"; +import { IdentityColumnsNotSupported } from "../exception/identity-columns-not-supported"; +import type { Result as DriverResult } from "../result"; +import type { Statement as DriverStatement } from "../statement"; +import { MSSQLStatement } from "./statement"; import type { MSSQLPoolLike, MSSQLRequestLike, MSSQLTransactionLike } from "./types"; export class MSSQLConnection implements DriverConnection { @@ -12,43 +16,37 @@ export class MSSQLConnection implements DriverConnection { private readonly ownsClient: boolean, ) {} - public async executeQuery(query: CompiledQuery): Promise { + public async prepare(sql: string): Promise { + return new MSSQLStatement(this, sql); + } + + public async query(sql: string): Promise { return this.runSerial(async () => { const request = this.createRequest(); - const namedParameters = this.toNamedParameters(query.parameters); - this.bindNamedParameters(request, namedParameters); - - const payload = await request.query(query.sql); - const rows = this.toRows(payload); - const firstRow = rows[0]; - - return { - columns: firstRow === undefined ? [] : Object.keys(firstRow), - rowCount: rows.length, - rows, - }; + const payload = await request.query(sql); + return this.toDriverResult(payload); }); } - public async executeStatement(query: CompiledQuery): Promise { + public quote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; + } + + public async exec(sql: string): Promise { return this.runSerial(async () => { const request = this.createRequest(); - const namedParameters = this.toNamedParameters(query.parameters); - this.bindNamedParameters(request, namedParameters); - - const payload = await request.query(query.sql); - const rowsAffected = this.getRowsAffected(payload); - - return { - affectedRows: rowsAffected, - insertId: null, - }; + const payload = await request.query(sql); + return this.getRowsAffected(payload); }); } + public async lastInsertId(): Promise { + throw IdentityColumnsNotSupported.new(); + } + public async beginTransaction(): Promise { if (this.transaction !== null) { - throw new DbalException("A transaction is already active on this connection."); + throw new Error("A transaction is already active on this connection."); } const transaction = this.pool.transaction(); @@ -59,7 +57,7 @@ export class MSSQLConnection implements DriverConnection { public async commit(): Promise { const transaction = this.transaction; if (transaction === null) { - throw new DbalException("No active transaction to commit."); + throw new Error("No active transaction to commit."); } await transaction.commit(); @@ -69,45 +67,19 @@ export class MSSQLConnection implements DriverConnection { public async rollBack(): Promise { const transaction = this.transaction; if (transaction === null) { - throw new DbalException("No active transaction to roll back."); + throw new Error("No active transaction to roll back."); } await transaction.rollback(); this.transaction = null; } - public async createSavepoint(name: string): Promise { - if (this.transaction === null) { - throw new DbalException("Cannot create a savepoint without an active transaction."); - } - - await this.transaction.request().query(`SAVE TRANSACTION ${name}`); - } - - public async releaseSavepoint(_name: string): Promise { - // SQL Server does not provide explicit savepoint release. - } - - public async rollbackSavepoint(name: string): Promise { - if (this.transaction === null) { - throw new DbalException("Cannot roll back a savepoint without an active transaction."); - } - - await this.transaction.request().query(`ROLLBACK TRANSACTION ${name}`); - } - - public quote(value: string): string { - return `'${value.replace(/'/g, "''")}'`; - } - public async getServerVersion(): Promise { - const request = this.createRequest(); - const payload = await request.query("SELECT @@VERSION AS version"); - const rows = this.toRows(payload); - const firstRow = rows[0]; - const version = firstRow?.version; + const result = await this.query("SELECT @@VERSION AS version"); + const version = result.fetchOne() ?? "unknown"; + result.free(); - return typeof version === "string" ? version : String(version ?? "unknown"); + return typeof version === "string" ? version : String(version); } public async close(): Promise { @@ -125,6 +97,19 @@ export class MSSQLConnection implements DriverConnection { return this.pool; } + public async executePrepared( + sql: string, + parameters: Record, + ): Promise { + return this.runSerial(async () => { + const request = this.createRequest(); + this.bindNamedParameters(request, parameters); + const payload = await request.query(sql); + + return this.toDriverResult(payload); + }); + } + private createRequest(): MSSQLRequestLike { if (this.transaction !== null) { return this.transaction.request(); @@ -142,9 +127,9 @@ export class MSSQLConnection implements DriverConnection { } } - private toNamedParameters(parameters: CompiledQuery["parameters"]): Record { - if (!Array.isArray(parameters)) { - return parameters; + public toNamedParameters(parameters: unknown): Record { + if (parameters !== null && typeof parameters === "object" && !Array.isArray(parameters)) { + return parameters as Record; } throw new InvalidParameterException( @@ -152,6 +137,17 @@ export class MSSQLConnection implements DriverConnection { ); } + private toDriverResult(payload: unknown): DriverResult { + const rows = this.toRows(payload); + const firstRow = rows[0]; + + return new ArrayResult( + rows, + firstRow === undefined ? [] : Object.keys(firstRow), + rows.length > 0 ? rows.length : this.getRowsAffected(payload), + ); + } + private toRows(payload: unknown): Array> { if (payload === null || typeof payload !== "object") { return []; diff --git a/src/driver/mssql/driver.ts b/src/driver/mssql/driver.ts index d462674..5dbd86a 100644 --- a/src/driver/mssql/driver.ts +++ b/src/driver/mssql/driver.ts @@ -1,23 +1,19 @@ -import { type Driver, type DriverConnection, ParameterBindingStyle } from "../../driver"; -import { DbalException } from "../../exception/index"; -import { SQLServerPlatform } from "../../platforms/sql-server-platform"; -import type { ServerVersionProvider } from "../../server-version-provider"; -import { ExceptionConverter as SQLSrvExceptionConverter } from "../api/sqlsrv/exception-converter"; +import type { DriverConnection } from "../../driver"; +import { AbstractSQLServerDriver } from "../abstract-sql-server-driver"; +import { ParameterBindingStyle } from "../internal-parameter-binding-style"; import { MSSQLConnection } from "./connection"; import type { MSSQLConnectionParams } from "./types"; -export class MSSQLDriver implements Driver { +export class MSSQLDriver extends AbstractSQLServerDriver { public readonly name = "mssql"; public readonly bindingStyle = ParameterBindingStyle.NAMED; - private readonly exceptionConverter = new SQLSrvExceptionConverter(); - private readonly platform = new SQLServerPlatform(); public async connect(params: Record): Promise { const connectionParams = params as MSSQLConnectionParams; const client = connectionParams.pool ?? connectionParams.connection ?? connectionParams.client; if (client === undefined) { - throw new DbalException( + throw new Error( "mssql connection requires one of `pool`, `connection`, or `client` in connection params.", ); } @@ -25,12 +21,4 @@ export class MSSQLDriver implements Driver { const ownsClient = Boolean(connectionParams.ownsPool ?? connectionParams.ownsClient); return new MSSQLConnection(client, ownsClient); } - - public getExceptionConverter(): SQLSrvExceptionConverter { - return this.exceptionConverter; - } - - public getDatabasePlatform(_versionProvider: ServerVersionProvider): SQLServerPlatform { - return this.platform; - } } diff --git a/src/driver/mssql/exception-converter.ts b/src/driver/mssql/exception-converter.ts index 45eb97a..22c7e23 100644 --- a/src/driver/mssql/exception-converter.ts +++ b/src/driver/mssql/exception-converter.ts @@ -1,3 +1,3 @@ -import { ExceptionConverter as SQLSrvExceptionConverter } from "../api/sqlsrv/exception-converter"; +import { ExceptionConverter as SQLSrvExceptionConverter } from "../api/sql-server/exception-converter"; export class MSSQLExceptionConverter extends SQLSrvExceptionConverter {} diff --git a/src/driver/mssql/statement.ts b/src/driver/mssql/statement.ts new file mode 100644 index 0000000..1cefd5b --- /dev/null +++ b/src/driver/mssql/statement.ts @@ -0,0 +1,31 @@ +import { ParameterType } from "../../parameter-type"; +import type { Result as DriverResult } from "../result"; +import type { Statement as DriverStatement } from "../statement"; +import type { MSSQLConnection } from "./connection"; + +export class MSSQLStatement implements DriverStatement { + private readonly parameters: Record = {}; + + constructor( + private readonly connection: MSSQLConnection, + private readonly sql: string, + ) {} + + public bindValue( + param: string | number, + value: unknown, + _type: ParameterType = ParameterType.STRING, + ): void { + if (typeof param === "number") { + this.parameters[`p${param}`] = value; + return; + } + + const name = param.startsWith(":") || param.startsWith("@") ? param.slice(1) : param; + this.parameters[name] = value; + } + + public async execute(): Promise { + return this.connection.executePrepared(this.sql, this.parameters); + } +} diff --git a/src/driver/mysql2/connection.ts b/src/driver/mysql2/connection.ts index 11951d3..c593287 100644 --- a/src/driver/mysql2/connection.ts +++ b/src/driver/mysql2/connection.ts @@ -1,51 +1,57 @@ -import type { DriverConnection, DriverExecutionResult, DriverQueryResult } from "../../driver"; -import { DbalException, InvalidParameterException } from "../../exception/index"; -import type { CompiledQuery } from "../../types"; +import { ArrayResult } from "../array-result"; +import type { Connection as DriverConnection } from "../connection"; +import { NoIdentityValue } from "../exception/no-identity-value"; +import type { Result as DriverResult } from "../result"; +import type { Statement as DriverStatement } from "../statement"; +import { MySQL2Statement } from "./statement"; import type { MySQL2ConnectionLike, MySQL2PoolLike } from "./types"; export class MySQL2Connection implements DriverConnection { private transactionConnection: MySQL2ConnectionLike | null = null; + private lastInsertIdValue: number | string | null = null; constructor( private readonly client: MySQL2PoolLike | MySQL2ConnectionLike, private readonly ownsClient: boolean, ) {} - public async executeQuery(query: CompiledQuery): Promise { - const parameters = this.toPositionalParameters(query.parameters); - const payload = await this.executeRaw(query.sql, parameters); + public async prepare(sql: string): Promise { + return new MySQL2Statement(this, sql); + } - const rows = this.toRows(payload); - const firstRow = rows[0]; + public async query(sql: string): Promise { + const payload = await this.executeRaw(sql, []); + return this.toDriverResult(payload); + } - return { - columns: firstRow === undefined ? [] : Object.keys(firstRow), - rowCount: rows.length, - rows, - }; + public quote(value: string): string { + return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`; } - public async executeStatement(query: CompiledQuery): Promise { - const parameters = this.toPositionalParameters(query.parameters); - const payload = await this.executeRaw(query.sql, parameters); + public async exec(sql: string): Promise { + const payload = await this.executeRaw(sql, []); const metadata = this.toMetadata(payload); + this.lastInsertIdValue = metadata.insertId; - return { - affectedRows: metadata.affectedRows, - insertId: metadata.insertId, - }; + return metadata.affectedRows; + } + + public async lastInsertId(): Promise { + if (this.lastInsertIdValue === null) { + throw NoIdentityValue.new(); + } + + return this.lastInsertIdValue; } public async beginTransaction(): Promise { if (this.transactionConnection !== null) { - throw new DbalException("A transaction is already active on this connection."); + throw new Error("A transaction is already active on this connection."); } const connection = await this.acquireTransactionConnection(); if (connection.beginTransaction === undefined) { - throw new DbalException( - "The provided mysql2 connection does not support beginTransaction().", - ); + throw new Error("The provided mysql2 connection does not support beginTransaction()."); } await connection.beginTransaction(); @@ -55,11 +61,11 @@ export class MySQL2Connection implements DriverConnection { public async commit(): Promise { const connection = this.transactionConnection; if (connection === null) { - throw new DbalException("No active transaction to commit."); + throw new Error("No active transaction to commit."); } if (connection.commit === undefined) { - throw new DbalException("The provided mysql2 connection does not support commit()."); + throw new Error("The provided mysql2 connection does not support commit()."); } try { @@ -73,11 +79,11 @@ export class MySQL2Connection implements DriverConnection { public async rollBack(): Promise { const connection = this.transactionConnection; if (connection === null) { - throw new DbalException("No active transaction to roll back."); + throw new Error("No active transaction to roll back."); } if (connection.rollback === undefined) { - throw new DbalException("The provided mysql2 connection does not support rollback()."); + throw new Error("The provided mysql2 connection does not support rollback()."); } try { @@ -88,29 +94,12 @@ export class MySQL2Connection implements DriverConnection { } } - public async createSavepoint(name: string): Promise { - await this.executeRaw(`SAVEPOINT ${name}`, []); - } - - public async releaseSavepoint(name: string): Promise { - await this.executeRaw(`RELEASE SAVEPOINT ${name}`, []); - } - - public async rollbackSavepoint(name: string): Promise { - await this.executeRaw(`ROLLBACK TO SAVEPOINT ${name}`, []); - } - - public quote(value: string): string { - return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`; - } - public async getServerVersion(): Promise { - const payload = await this.executeRaw("SELECT VERSION() AS version", []); - const rows = this.toRows(payload); - const firstRow = rows[0]; - const version = firstRow?.version; + const result = await this.query("SELECT VERSION() AS version"); + const version = result.fetchOne() ?? "unknown"; + result.free(); - return typeof version === "string" ? version : String(version ?? "unknown"); + return typeof version === "string" ? version : String(version); } public async close(): Promise { @@ -128,6 +117,11 @@ export class MySQL2Connection implements DriverConnection { return this.transactionConnection ?? this.client; } + public async executePrepared(sql: string, parameters: unknown[]): Promise { + const payload = await this.executeRaw(sql, parameters); + return this.toDriverResult(payload); + } + private async acquireTransactionConnection(): Promise { if ("getConnection" in this.client && typeof this.client.getConnection === "function") { return this.client.getConnection(); @@ -155,7 +149,7 @@ export class MySQL2Connection implements DriverConnection { return this.unwrapDriverResult(result); } - throw new DbalException("The provided mysql2 client does not expose query() or execute()."); + throw new Error("The provided mysql2 client does not expose query() or execute()."); } private unwrapDriverResult(result: unknown): unknown { @@ -166,6 +160,24 @@ export class MySQL2Connection implements DriverConnection { return result[0]; } + private toDriverResult(result: unknown): DriverResult { + const rows = this.toRows(result); + if (rows.length > 0) { + const firstRow = rows[0]; + + return new ArrayResult( + rows, + firstRow === undefined ? [] : Object.keys(firstRow), + rows.length, + ); + } + + const metadata = this.toMetadata(result); + this.lastInsertIdValue = metadata.insertId; + + return new ArrayResult([], [], metadata.affectedRows); + } + private toRows(result: unknown): Array> { if (!Array.isArray(result)) { return []; @@ -207,14 +219,4 @@ export class MySQL2Connection implements DriverConnection { insertId: null, }; } - - private toPositionalParameters(parameters: CompiledQuery["parameters"]): unknown[] { - if (Array.isArray(parameters)) { - return parameters; - } - - throw new InvalidParameterException( - "The mysql2 driver expects positional parameters after SQL compilation.", - ); - } } diff --git a/src/driver/mysql2/driver.ts b/src/driver/mysql2/driver.ts index 8ac11a9..4419b92 100644 --- a/src/driver/mysql2/driver.ts +++ b/src/driver/mysql2/driver.ts @@ -1,33 +1,19 @@ -import { coerce, gte } from "semver"; - -import { type Driver, type DriverConnection, ParameterBindingStyle } from "../../driver"; -import { DbalException } from "../../exception/index"; -import { AbstractMySQLPlatform } from "../../platforms/abstract-mysql-platform"; -import { InvalidPlatformVersion } from "../../platforms/exception/invalid-platform-version"; -import { MariaDBPlatform } from "../../platforms/mariadb-platform"; -import { MariaDB1010Platform } from "../../platforms/mariadb1010-platform"; -import { MariaDB1052Platform } from "../../platforms/mariadb1052-platform"; -import { MariaDB1060Platform } from "../../platforms/mariadb1060-platform"; -import { MariaDB110700Platform } from "../../platforms/mariadb110700-platform"; -import { MySQLPlatform } from "../../platforms/mysql-platform"; -import { MySQL80Platform } from "../../platforms/mysql80-platform"; -import { MySQL84Platform } from "../../platforms/mysql84-platform"; -import { type ServerVersionProvider } from "../../server-version-provider"; -import { ExceptionConverter as MySQLExceptionConverter } from "../api/mysql/exception-converter"; +import type { DriverConnection } from "../../driver"; +import { AbstractMySQLDriver } from "../abstract-mysql-driver"; +import { ParameterBindingStyle } from "../internal-parameter-binding-style"; import { MySQL2Connection } from "./connection"; import type { MySQL2ConnectionParams } from "./types"; -export class MySQL2Driver implements Driver { +export class MySQL2Driver extends AbstractMySQLDriver { public readonly name = "mysql2"; public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; - private readonly exceptionConverter = new MySQLExceptionConverter(); public async connect(params: Record): Promise { const connectionParams = params as MySQL2ConnectionParams; const client = connectionParams.pool ?? connectionParams.connection ?? connectionParams.client; if (client === undefined) { - throw new DbalException( + throw new Error( "mysql2 connection requires one of `pool`, `connection`, or `client` in connection params.", ); } @@ -35,68 +21,4 @@ export class MySQL2Driver implements Driver { const ownsClient = Boolean(connectionParams.ownsPool ?? connectionParams.ownsClient); return new MySQL2Connection(client, ownsClient); } - - public getExceptionConverter(): MySQLExceptionConverter { - return this.exceptionConverter; - } - - public getDatabasePlatform(versionProvider: ServerVersionProvider): AbstractMySQLPlatform { - const version = versionProvider.getServerVersion(); - - if (typeof version !== "string") { - // Best effort parity: async providers can't be consumed from this sync API. - return new MySQLPlatform(); - } - - if (version.toLowerCase().includes("mariadb")) { - const mariaDbVersion = this.getMariaDbMysqlVersionNumber(version); - if (gte(mariaDbVersion, "11.7.0")) { - return new MariaDB110700Platform(); - } - - if (gte(mariaDbVersion, "10.10.0")) { - return new MariaDB1010Platform(); - } - - if (gte(mariaDbVersion, "10.6.0")) { - return new MariaDB1060Platform(); - } - - if (gte(mariaDbVersion, "10.5.2")) { - return new MariaDB1052Platform(); - } - - return new MariaDBPlatform(); - } - - const mysqlVersion = coerce(version)?.version; - if (mysqlVersion === undefined) { - throw InvalidPlatformVersion.new(version, ".."); - } - - if (mysqlVersion !== undefined && gte(mysqlVersion, "8.4.0")) { - return new MySQL84Platform(); - } - - if (mysqlVersion !== undefined && gte(mysqlVersion, "8.0.0")) { - return new MySQL80Platform(); - } - - return new MySQLPlatform(); - } - - private getMariaDbMysqlVersionNumber(versionString: string): string { - const match = /^(?:5\.5\.5-)?(?:mariadb-)?(?\d+)\.(?\d+)\.(?\d+)/i.exec( - versionString, - ); - - if (match?.groups === undefined) { - throw InvalidPlatformVersion.new( - versionString, - "^(?:5.5.5-)?(mariadb-)?..", - ); - } - - return `${match.groups.major}.${match.groups.minor}.${match.groups.patch}`; - } } diff --git a/src/driver/mysql2/statement.ts b/src/driver/mysql2/statement.ts new file mode 100644 index 0000000..179b3e1 --- /dev/null +++ b/src/driver/mysql2/statement.ts @@ -0,0 +1,30 @@ +import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; +import { ParameterType } from "../../parameter-type"; +import type { Result as DriverResult } from "../result"; +import type { Statement as DriverStatement } from "../statement"; +import type { MySQL2Connection } from "./connection"; + +export class MySQL2Statement implements DriverStatement { + private readonly parameters: unknown[] = []; + + constructor( + private readonly connection: MySQL2Connection, + private readonly sql: string, + ) {} + + public bindValue( + param: string | number, + value: unknown, + _type: ParameterType = ParameterType.STRING, + ): void { + if (typeof param !== "number") { + throw new InvalidParameterException("The mysql2 driver supports positional parameters only."); + } + + this.parameters[Math.max(0, param - 1)] = value; + } + + public async execute(): Promise { + return this.connection.executePrepared(this.sql, this.parameters); + } +} diff --git a/src/driver/pg/connection.ts b/src/driver/pg/connection.ts index bd12972..eb57520 100644 --- a/src/driver/pg/connection.ts +++ b/src/driver/pg/connection.ts @@ -1,7 +1,11 @@ -import type { DriverConnection, DriverExecutionResult, DriverQueryResult } from "../../driver"; -import { DbalException, InvalidParameterException } from "../../exception/index"; +import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; import { Parser, type Visitor } from "../../sql/parser"; -import type { CompiledQuery } from "../../types"; +import { ArrayResult } from "../array-result"; +import type { Connection as DriverConnection } from "../connection"; +import { IdentityColumnsNotSupported } from "../exception/identity-columns-not-supported"; +import type { Result as DriverResult } from "../result"; +import type { Statement as DriverStatement } from "../statement"; +import { PgStatement } from "./statement"; import type { PgPoolClientLike, PgPoolLike, PgQueryResultLike, PgQueryableLike } from "./types"; export class PgConnection implements DriverConnection { @@ -14,34 +18,31 @@ export class PgConnection implements DriverConnection { private readonly ownsClient: boolean, ) {} - public async executeQuery(query: CompiledQuery): Promise { - const parameters = this.toPositionalParameters(query.parameters); - const sql = this.convertPositionalPlaceholders(query.sql); - const payload = await this.getQueryable().query(sql, parameters); - const rows = this.toRows(payload); - const firstRow = rows[0]; + public async prepare(sql: string): Promise { + return new PgStatement(this, sql); + } - return { - columns: this.toColumns(payload, firstRow), - rowCount: typeof payload.rowCount === "number" ? payload.rowCount : rows.length, - rows, - }; + public async query(sql: string): Promise { + const payload = await this.getQueryable().query(sql); + return this.toDriverResult(payload); } - public async executeStatement(query: CompiledQuery): Promise { - const parameters = this.toPositionalParameters(query.parameters); - const sql = this.convertPositionalPlaceholders(query.sql); - const payload = await this.getQueryable().query(sql, parameters); + public quote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; + } - return { - affectedRows: typeof payload.rowCount === "number" ? payload.rowCount : 0, - insertId: null, - }; + public async exec(sql: string): Promise { + const payload = await this.getQueryable().query(sql); + return typeof payload.rowCount === "number" ? payload.rowCount : 0; + } + + public async lastInsertId(): Promise { + throw IdentityColumnsNotSupported.new(); } public async beginTransaction(): Promise { if (this.inTransaction) { - throw new DbalException("A transaction is already active on this connection."); + throw new Error("A transaction is already active on this connection."); } if (this.transactionClient === null && this.isPool(this.client)) { @@ -54,7 +55,7 @@ export class PgConnection implements DriverConnection { public async commit(): Promise { if (!this.inTransaction) { - throw new DbalException("No active transaction to commit."); + throw new Error("No active transaction to commit."); } try { @@ -67,7 +68,7 @@ export class PgConnection implements DriverConnection { public async rollBack(): Promise { if (!this.inTransaction) { - throw new DbalException("No active transaction to roll back."); + throw new Error("No active transaction to roll back."); } try { @@ -78,30 +79,15 @@ export class PgConnection implements DriverConnection { } } - public async createSavepoint(name: string): Promise { - await this.getQueryable().query(`SAVEPOINT ${name}`); - } - - public async releaseSavepoint(name: string): Promise { - await this.getQueryable().query(`RELEASE SAVEPOINT ${name}`); - } - - public async rollbackSavepoint(name: string): Promise { - await this.getQueryable().query(`ROLLBACK TO SAVEPOINT ${name}`); - } - - public quote(value: string): string { - return `'${value.replace(/'/g, "''")}'`; - } - public async getServerVersion(): Promise { - const result = await this.getQueryable().query("SHOW server_version"); - const rows = this.toRows(result); - const firstRow = rows[0]; + const result = await this.query("SHOW server_version"); + const row = result.fetchAssociative(); + result.free(); const version = - firstRow?.server_version ?? firstRow?.serverVersion ?? firstRow?.version ?? "unknown"; - + row !== false + ? (row.server_version ?? row.serverVersion ?? row.version ?? "unknown") + : "unknown"; return typeof version === "string" ? version : String(version); } @@ -127,6 +113,13 @@ export class PgConnection implements DriverConnection { return this.transactionClient ?? this.client; } + public async executePrepared(sql: string, parameters: unknown[]): Promise { + const convertedSql = this.convertPositionalPlaceholders(sql); + const payload = await this.getQueryable().query(convertedSql, parameters); + + return this.toDriverResult(payload); + } + private getQueryable(): PgQueryableLike { return this.transactionClient ?? this.client; } @@ -168,13 +161,14 @@ export class PgConnection implements DriverConnection { return parts.join(""); } - private toPositionalParameters(parameters: CompiledQuery["parameters"]): unknown[] { - if (Array.isArray(parameters)) { - return parameters; - } + private toDriverResult(payload: PgQueryResultLike): DriverResult { + const rows = this.toRows(payload); + const firstRow = rows[0]; - throw new InvalidParameterException( - "The pg driver expects positional parameters after SQL compilation.", + return new ArrayResult( + rows, + this.toColumns(payload, firstRow), + payload.rowCount ?? rows.length, ); } diff --git a/src/driver/pg/driver.ts b/src/driver/pg/driver.ts index d9bb67b..4494414 100644 --- a/src/driver/pg/driver.ts +++ b/src/driver/pg/driver.ts @@ -1,26 +1,19 @@ -import { type Driver, type DriverConnection, ParameterBindingStyle } from "../../driver"; -import { DbalException } from "../../exception/index"; -import { InvalidPlatformVersion } from "../../platforms/exception/invalid-platform-version"; -import { PostgreSQLPlatform } from "../../platforms/postgre-sql-platform"; -import { PostgreSQL120Platform } from "../../platforms/postgre-sql120-platform"; -import type { ServerVersionProvider } from "../../server-version-provider"; -import { ExceptionConverter as PgSQLExceptionConverter } from "../api/pgsql/exception-converter"; +import type { DriverConnection } from "../../driver"; +import { AbstractPostgreSQLDriver } from "../abstract-postgre-sql-driver"; +import { ParameterBindingStyle } from "../internal-parameter-binding-style"; import { PgConnection } from "./connection"; import type { PgConnectionParams } from "./types"; -export class PgDriver implements Driver { +export class PgDriver extends AbstractPostgreSQLDriver { public readonly name = "pg"; public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; - private readonly exceptionConverter = new PgSQLExceptionConverter(); - private readonly platform = new PostgreSQLPlatform(); - private readonly platform120 = new PostgreSQL120Platform(); public async connect(params: Record): Promise { const connectionParams = params as PgConnectionParams; const client = connectionParams.pool ?? connectionParams.connection ?? connectionParams.client; if (client === undefined) { - throw new DbalException( + throw new Error( "pg connection requires one of `pool`, `connection`, or `client` in connection params.", ); } @@ -28,31 +21,4 @@ export class PgDriver implements Driver { const ownsClient = Boolean(connectionParams.ownsPool ?? connectionParams.ownsClient); return new PgConnection(client, ownsClient); } - - public getExceptionConverter(): PgSQLExceptionConverter { - return this.exceptionConverter; - } - - public getDatabasePlatform(_versionProvider: ServerVersionProvider): PostgreSQLPlatform { - const version = _versionProvider.getServerVersion(); - if (typeof version !== "string") { - return this.platform; - } - - const match = /^(?\d+)(?:\.(?\d+)(?:\.(?\d+))?)?/.exec(version); - if (match?.groups === undefined) { - throw InvalidPlatformVersion.new(version, ".."); - } - - const major = Number.parseInt(match.groups.major ?? "0", 10); - if (Number.isNaN(major)) { - throw InvalidPlatformVersion.new(version, ".."); - } - - if (major >= 12) { - return this.platform120; - } - - return this.platform; - } } diff --git a/src/driver/pg/statement.ts b/src/driver/pg/statement.ts new file mode 100644 index 0000000..dc67f63 --- /dev/null +++ b/src/driver/pg/statement.ts @@ -0,0 +1,30 @@ +import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; +import { ParameterType } from "../../parameter-type"; +import type { Result as DriverResult } from "../result"; +import type { Statement as DriverStatement } from "../statement"; +import type { PgConnection } from "./connection"; + +export class PgStatement implements DriverStatement { + private readonly parameters: unknown[] = []; + + constructor( + private readonly connection: PgConnection, + private readonly sql: string, + ) {} + + public bindValue( + param: string | number, + value: unknown, + _type: ParameterType = ParameterType.STRING, + ): void { + if (typeof param !== "number") { + throw new InvalidParameterException("The pg driver supports positional parameters only."); + } + + this.parameters[Math.max(0, param - 1)] = value; + } + + public async execute(): Promise { + return this.connection.executePrepared(this.sql, this.parameters); + } +} diff --git a/src/driver/result.ts b/src/driver/result.ts new file mode 100644 index 0000000..3f7b9f1 --- /dev/null +++ b/src/driver/result.ts @@ -0,0 +1,78 @@ +type AssociativeRow = Record; + +/** + * Driver-level statement execution result. + */ +export interface Result { + /** + * Returns the next row of the result as a numeric array or FALSE if there are no more rows. + * + * @throws Exception + */ + fetchNumeric(): T[] | false; + + /** + * Returns the next row of the result as an associative array or FALSE if there are no more rows. + * + * @throws Exception + */ + fetchAssociative(): T | false; + + /** + * Returns the first value of the next row of the result or FALSE if there are no more rows. + * + * @throws Exception + */ + fetchOne(): T | false; + + /** + * Returns an array containing all of the result rows represented as numeric arrays. + * + * @throws Exception + */ + fetchAllNumeric(): T[][]; + + /** + * Returns an array containing all of the result rows represented as associative arrays. + * + * @throws Exception + */ + fetchAllAssociative(): T[]; + + /** + * Returns an array containing the values of the first column of the result. + * + * @throws Exception + */ + fetchFirstColumn(): T[]; + + /** + * Returns the number of rows affected by the DELETE, INSERT, or UPDATE statement that produced the result. + * + * If the statement executed a SELECT query or a similar platform-specific SQL (e.g. DESCRIBE, SHOW, etc.), + * some database drivers may return the number of rows returned by that query. However, this behaviour + * is not guaranteed for all drivers and should not be relied on in portable applications. + * + * If the number of rows exceeds {@see PHP_INT_MAX}, it might be returned as string if the driver supports it. + * + * @return int|numeric-string + * + * @throws Exception + */ + rowCount(): number | string; + + /** + * Returns the number of columns in the result + * + * @return int The number of columns in the result. If the columns cannot be counted, + * this method must return 0. + * + * @throws Exception + */ + columnCount(): number; + + /** + * Discards the non-fetched portion of the result, enabling the originating statement to be executed again. + */ + free(): void; +} diff --git a/src/driver/sqlite3/connection.ts b/src/driver/sqlite3/connection.ts index c6e0e5c..a9c40e5 100644 --- a/src/driver/sqlite3/connection.ts +++ b/src/driver/sqlite3/connection.ts @@ -1,81 +1,78 @@ -import type { DriverConnection, DriverExecutionResult, DriverQueryResult } from "../../driver"; -import { DbalException, InvalidParameterException } from "../../exception/index"; -import type { CompiledQuery } from "../../types"; +import { ArrayResult } from "../array-result"; +import type { Connection as DriverConnection } from "../connection"; +import { NoIdentityValue } from "../exception/no-identity-value"; +import type { Result as DriverResult } from "../result"; +import type { Statement as DriverStatement } from "../statement"; +import { SQLite3Statement } from "./statement"; import type { SQLite3DatabaseLike, SQLite3RunContextLike } from "./types"; export class SQLite3Connection implements DriverConnection { private inTransaction = false; + private lastInsertIdValue: number | string | null = null; constructor( private readonly database: SQLite3DatabaseLike, private readonly ownsClient: boolean, ) {} - public async executeQuery(query: CompiledQuery): Promise { - const parameters = this.toPositionalParameters(query.parameters); - const rows = await this.queryAll(query.sql, parameters); + public async prepare(sql: string): Promise { + return new SQLite3Statement(this, sql); + } + + public async query(sql: string): Promise { + const rows = await this.queryAll(sql, []); const firstRow = rows[0]; - return { - columns: firstRow === undefined ? [] : Object.keys(firstRow), - rowCount: rows.length, - rows, - }; + return new ArrayResult(rows, firstRow === undefined ? [] : Object.keys(firstRow), rows.length); + } + + public quote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; } - public async executeStatement(query: CompiledQuery): Promise { - const parameters = this.toPositionalParameters(query.parameters); - const result = await this.queryRun(query.sql, parameters); + public async exec(sql: string): Promise { + const result = await this.queryRun(sql, []); + this.lastInsertIdValue = + typeof result.lastID === "number" || typeof result.lastID === "string" ? result.lastID : null; - return { - affectedRows: result.changes ?? 0, - insertId: result.lastID ?? null, - }; + return typeof result.changes === "number" ? result.changes : 0; + } + + public async lastInsertId(): Promise { + if (this.lastInsertIdValue === null) { + throw NoIdentityValue.new(); + } + + return this.lastInsertIdValue; } public async beginTransaction(): Promise { if (this.inTransaction) { - throw new DbalException("A transaction is already active on this connection."); + throw new Error("A transaction is already active on this connection."); } - await this.exec("BEGIN"); + await this.execSql("BEGIN"); this.inTransaction = true; } public async commit(): Promise { if (!this.inTransaction) { - throw new DbalException("No active transaction to commit."); + throw new Error("No active transaction to commit."); } - await this.exec("COMMIT"); + await this.execSql("COMMIT"); this.inTransaction = false; } public async rollBack(): Promise { if (!this.inTransaction) { - throw new DbalException("No active transaction to roll back."); + throw new Error("No active transaction to roll back."); } - await this.exec("ROLLBACK"); + await this.execSql("ROLLBACK"); this.inTransaction = false; } - public async createSavepoint(name: string): Promise { - await this.exec(`SAVEPOINT ${name}`); - } - - public async releaseSavepoint(name: string): Promise { - await this.exec(`RELEASE SAVEPOINT ${name}`); - } - - public async rollbackSavepoint(name: string): Promise { - await this.exec(`ROLLBACK TO SAVEPOINT ${name}`); - } - - public quote(value: string): string { - return `'${value.replace(/'/g, "''")}'`; - } - public async getServerVersion(): Promise { const rows = await this.queryAll("SELECT sqlite_version() AS version", []); const version = rows[0]?.version ?? "unknown"; @@ -86,7 +83,7 @@ export class SQLite3Connection implements DriverConnection { public async close(): Promise { if (this.inTransaction) { try { - await this.exec("ROLLBACK"); + await this.execSql("ROLLBACK"); } catch { // best effort rollback during close } finally { @@ -94,11 +91,7 @@ export class SQLite3Connection implements DriverConnection { } } - if (!this.ownsClient) { - return; - } - - if (this.database.close === undefined) { + if (!this.ownsClient || this.database.close === undefined) { return; } @@ -118,13 +111,33 @@ export class SQLite3Connection implements DriverConnection { return this.database; } - private toPositionalParameters(parameters: CompiledQuery["parameters"]): unknown[] { - if (Array.isArray(parameters)) { - return parameters; + public async executePrepared(sql: string, parameters: unknown[]): Promise { + if (this.isResultSetSql(sql)) { + const rows = await this.queryAll(sql, parameters); + const firstRow = rows[0]; + + return new ArrayResult( + rows, + firstRow === undefined ? [] : Object.keys(firstRow), + rows.length, + ); } - throw new InvalidParameterException( - "The sqlite3 driver expects positional parameters after SQL compilation.", + const result = await this.queryRun(sql, parameters); + this.lastInsertIdValue = + typeof result.lastID === "number" || typeof result.lastID === "string" ? result.lastID : null; + + return new ArrayResult([], [], typeof result.changes === "number" ? result.changes : 0); + } + + private isResultSetSql(sql: string): boolean { + const normalized = sql.trimStart().toUpperCase(); + + return ( + normalized.startsWith("SELECT") || + normalized.startsWith("PRAGMA") || + normalized.startsWith("WITH") || + normalized.startsWith("EXPLAIN") ); } @@ -133,7 +146,7 @@ export class SQLite3Connection implements DriverConnection { parameters: unknown[], ): Promise>> { if (this.database.all === undefined) { - throw new DbalException("The provided sqlite3 database does not expose all()."); + throw new Error("The provided sqlite3 database does not expose all()."); } const rows = await new Promise((resolve, reject) => { @@ -159,7 +172,12 @@ export class SQLite3Connection implements DriverConnection { private async queryRun(sql: string, parameters: unknown[]): Promise { if (this.database.run === undefined) { - throw new DbalException("The provided sqlite3 database does not expose run()."); + if (parameters.length === 0 && this.database.exec !== undefined) { + await this.execSql(sql); + return { changes: 0 }; + } + + throw new Error("The provided sqlite3 database does not expose run()."); } return new Promise((resolve, reject) => { @@ -171,13 +189,16 @@ export class SQLite3Connection implements DriverConnection { resolve({ changes: typeof this?.changes === "number" ? this.changes : 0, - lastID: typeof this?.lastID === "number" ? this.lastID : undefined, + lastID: + typeof this?.lastID === "number" || typeof this?.lastID === "string" + ? this.lastID + : undefined, }); }); }); } - private async exec(sql: string): Promise { + private async execSql(sql: string): Promise { if (this.database.exec !== undefined) { await new Promise((resolve, reject) => { this.database.exec?.(sql, (error) => { diff --git a/src/driver/sqlite3/driver.ts b/src/driver/sqlite3/driver.ts index a8c3ceb..b27ff8d 100644 --- a/src/driver/sqlite3/driver.ts +++ b/src/driver/sqlite3/driver.ts @@ -1,16 +1,12 @@ -import { type Driver, type DriverConnection, ParameterBindingStyle } from "../../driver"; -import { DbalException } from "../../exception/index"; -import { SQLitePlatform } from "../../platforms/sqlite-platform"; -import type { ServerVersionProvider } from "../../server-version-provider"; -import { ExceptionConverter as SQLiteExceptionConverter } from "../api/sqlite/exception-converter"; +import type { DriverConnection } from "../../driver"; +import { AbstractSQLiteDriver } from "../abstract-sqlite-driver"; +import { ParameterBindingStyle } from "../internal-parameter-binding-style"; import { SQLite3Connection } from "./connection"; import type { SQLite3ConnectionParams } from "./types"; -export class SQLite3Driver implements Driver { +export class SQLite3Driver extends AbstractSQLiteDriver { public readonly name = "sqlite3"; public readonly bindingStyle = ParameterBindingStyle.POSITIONAL; - private readonly exceptionConverter = new SQLiteExceptionConverter(); - private readonly platform = new SQLitePlatform(); public async connect(params: Record): Promise { const connectionParams = params as SQLite3ConnectionParams; @@ -18,19 +14,11 @@ export class SQLite3Driver implements Driver { connectionParams.database ?? connectionParams.connection ?? connectionParams.client; if (client === undefined) { - throw new DbalException( + throw new Error( "sqlite3 connection requires one of `database`, `connection`, or `client` in connection params.", ); } return new SQLite3Connection(client, Boolean(connectionParams.ownsClient)); } - - public getExceptionConverter(): SQLiteExceptionConverter { - return this.exceptionConverter; - } - - public getDatabasePlatform(_versionProvider: ServerVersionProvider): SQLitePlatform { - return this.platform; - } } diff --git a/src/driver/sqlite3/statement.ts b/src/driver/sqlite3/statement.ts new file mode 100644 index 0000000..82157ce --- /dev/null +++ b/src/driver/sqlite3/statement.ts @@ -0,0 +1,32 @@ +import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; +import { ParameterType } from "../../parameter-type"; +import type { Result as DriverResult } from "../result"; +import type { Statement as DriverStatement } from "../statement"; +import type { SQLite3Connection } from "./connection"; + +export class SQLite3Statement implements DriverStatement { + private readonly parameters: unknown[] = []; + + constructor( + private readonly connection: SQLite3Connection, + private readonly sql: string, + ) {} + + public bindValue( + param: string | number, + value: unknown, + _type: ParameterType = ParameterType.STRING, + ): void { + if (typeof param !== "number") { + throw new InvalidParameterException( + "The sqlite3 driver supports positional parameters only.", + ); + } + + this.parameters[Math.max(0, param - 1)] = value; + } + + public async execute(): Promise { + return this.connection.executePrepared(this.sql, this.parameters); + } +} diff --git a/src/driver/statement.ts b/src/driver/statement.ts new file mode 100644 index 0000000..f2e94d7 --- /dev/null +++ b/src/driver/statement.ts @@ -0,0 +1,30 @@ +import { ParameterType } from "../parameter-type"; +import type { Result } from "./result"; + +export interface Statement { + /** + * Binds a value to a corresponding named or positional + * placeholder in the SQL statement that was used to prepare the statement. + * + * As mentioned above, the named parameters are not natively supported by the mysql2 driver, use executeQuery(), + * fetchAll(), fetchArray(), fetchColumn(), fetchAssoc() methods to have the named parameter emulated by datazen. + * + * @param number|string param Parameter identifier. For a prepared statement using named placeholders, + * this will be a parameter name of the form :name. For a prepared statement + * using question mark placeholders, this will be the 1-indexed position + * of the parameter. + * @param mixed value The value to bind to the parameter. + * @param ParameterType type Explicit data type for the parameter using the {@see ParameterType} + * constants. + * + * @throws Exception + */ + bindValue(param: string | number, value: unknown, type?: ParameterType): void; + + /** + * Executes a prepared statement + * + * @throws Exception + */ + execute(): Promise; +} diff --git a/src/exception/_util.ts b/src/exception/_util.ts new file mode 100644 index 0000000..faf0e8c --- /dev/null +++ b/src/exception/_util.ts @@ -0,0 +1,25 @@ +const DBAL_EXCEPTION_MARKER = Symbol.for("@devscast/datazen.exception"); + +type MarkedException = Error & { + [DBAL_EXCEPTION_MARKER]?: true; +}; + +type ErrorConstructorLike = { + name: string; + prototype: object; +}; + +export function initializeException(error: Error, ctor: ErrorConstructorLike): void { + error.name = ctor.name; + Object.setPrototypeOf(error, ctor.prototype); + Object.defineProperty(error as MarkedException, DBAL_EXCEPTION_MARKER, { + configurable: false, + enumerable: false, + value: true, + writable: false, + }); +} + +export function isDoctrineException(error: unknown): error is Error { + return error instanceof Error && (error as MarkedException)[DBAL_EXCEPTION_MARKER] === true; +} diff --git a/src/exception/commit-failed-rollback-only.ts b/src/exception/commit-failed-rollback-only.ts new file mode 100644 index 0000000..5a1dd8e --- /dev/null +++ b/src/exception/commit-failed-rollback-only.ts @@ -0,0 +1,15 @@ +import { ConnectionException } from "./connection-exception"; +import type { DriverExceptionDetails } from "./driver-exception"; + +export class CommitFailedRollbackOnly extends ConnectionException { + public static new(details?: Partial): CommitFailedRollbackOnly { + return new CommitFailedRollbackOnly( + "Transaction commit failed because the transaction has been marked for rollback only.", + { + driverName: "driver", + operation: "commit", + ...details, + }, + ); + } +} diff --git a/src/exception/connection-lost.ts b/src/exception/connection-lost.ts new file mode 100644 index 0000000..b8a59a5 --- /dev/null +++ b/src/exception/connection-lost.ts @@ -0,0 +1,3 @@ +import { ConnectionException } from "./connection-exception"; + +export class ConnectionLost extends ConnectionException {} diff --git a/src/exception/constraint-violation-exception.ts b/src/exception/constraint-violation-exception.ts index 7e08445..3a11281 100644 --- a/src/exception/constraint-violation-exception.ts +++ b/src/exception/constraint-violation-exception.ts @@ -1,3 +1,3 @@ -import { DriverException } from "./driver-exception"; +import { ServerException } from "./server-exception"; -export class ConstraintViolationException extends DriverException {} +export class ConstraintViolationException extends ServerException {} diff --git a/src/exception/database-does-not-exist.ts b/src/exception/database-does-not-exist.ts new file mode 100644 index 0000000..d39a944 --- /dev/null +++ b/src/exception/database-does-not-exist.ts @@ -0,0 +1,3 @@ +import { DatabaseObjectNotFoundException } from "./database-object-not-found-exception"; + +export class DatabaseDoesNotExist extends DatabaseObjectNotFoundException {} diff --git a/src/exception/database-object-exists-exception.ts b/src/exception/database-object-exists-exception.ts new file mode 100644 index 0000000..b63309e --- /dev/null +++ b/src/exception/database-object-exists-exception.ts @@ -0,0 +1,3 @@ +import { ServerException } from "./server-exception"; + +export class DatabaseObjectExistsException extends ServerException {} diff --git a/src/exception/database-object-not-found-exception.ts b/src/exception/database-object-not-found-exception.ts new file mode 100644 index 0000000..01f6418 --- /dev/null +++ b/src/exception/database-object-not-found-exception.ts @@ -0,0 +1,10 @@ +import { ServerException } from "./server-exception"; + +/** + * Base class for all unknown database object related errors detected in the driver. + * + * A database object is considered any asset that can be created in a database + * such as schemas, tables, views, sequences, triggers, constraints, indexes, + * functions, stored procedures etc. + */ +export class DatabaseObjectNotFoundException extends ServerException {} diff --git a/src/exception/database-required.ts b/src/exception/database-required.ts new file mode 100644 index 0000000..059730a --- /dev/null +++ b/src/exception/database-required.ts @@ -0,0 +1,12 @@ +import { initializeException } from "./_util"; + +export class DatabaseRequired extends Error { + constructor(message: string) { + super(message); + initializeException(this, new.target); + } + + public static new(methodName: string): DatabaseRequired { + return new DatabaseRequired(`A database is required for the method: ${methodName}.`); + } +} diff --git a/src/exception/dbal-exception.ts b/src/exception/dbal-exception.ts deleted file mode 100644 index c409598..0000000 --- a/src/exception/dbal-exception.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class DbalException extends Error { - constructor(message: string) { - super(message); - this.name = new.target.name; - Object.setPrototypeOf(this, new.target.prototype); - } -} diff --git a/src/exception/deadlock-exception.ts b/src/exception/deadlock-exception.ts index 6f063ef..61c5650 100644 --- a/src/exception/deadlock-exception.ts +++ b/src/exception/deadlock-exception.ts @@ -1,3 +1,3 @@ -import { DriverException } from "./driver-exception"; +import { ServerException } from "./server-exception"; -export class DeadlockException extends DriverException {} +export class DeadlockException extends ServerException {} diff --git a/src/exception/driver-exception.ts b/src/exception/driver-exception.ts index 6808d32..87a5119 100644 --- a/src/exception/driver-exception.ts +++ b/src/exception/driver-exception.ts @@ -1,4 +1,5 @@ -import { DbalException } from "./dbal-exception"; +import { Query } from "../query"; +import { initializeException } from "./_util"; export interface DriverExceptionDetails { driverName: string; @@ -10,30 +11,108 @@ export interface DriverExceptionDetails { cause?: unknown; } -export class DriverException extends DbalException { +export class DriverException extends Error { public readonly driverName: string; public readonly operation: string; public readonly sql?: string; public readonly parameters?: unknown; public readonly code?: number | string; public readonly sqlState?: string; + private readonly query: Query | null; - constructor(message: string, details: DriverExceptionDetails) { - super(message); - this.driverName = details.driverName; - this.operation = details.operation; - this.sql = details.sql; - this.parameters = details.parameters; - this.code = details.code; - this.sqlState = details.sqlState; - - if (details.cause !== undefined) { - Object.defineProperty(this, "cause", { - configurable: true, - enumerable: false, - value: details.cause, - writable: true, - }); + constructor(message: string, details: DriverExceptionDetails); + constructor(driverException: Error, query?: Query | null); + constructor( + messageOrDriverException: string | Error, + detailsOrQuery?: DriverExceptionDetails | Query | null, + ) { + if (typeof messageOrDriverException === "string") { + const details = detailsOrQuery as DriverExceptionDetails; + + super(messageOrDriverException); + initializeException(this, new.target); + this.driverName = details.driverName; + this.operation = details.operation; + this.sql = details.sql; + this.parameters = details.parameters; + this.code = details.code; + this.sqlState = details.sqlState; + this.query = null; + + if (details.cause !== undefined) { + Object.defineProperty(this, "cause", { + configurable: true, + enumerable: false, + value: details.cause, + writable: true, + }); + } + + return; } + + const driverException = messageOrDriverException; + const query = detailsOrQuery instanceof Query ? detailsOrQuery : null; + const wrappedMessage = + query !== null + ? `An exception occurred while executing a query: ${driverException.message}` + : `An exception occurred in the driver: ${driverException.message}`; + + super(wrappedMessage); + initializeException(this, new.target); + this.driverName = driverException.constructor.name || "driver"; + this.operation = query !== null ? "query" : "driver"; + this.sql = query?.sql; + this.parameters = query?.parameters; + this.code = this.readCode(driverException); + this.sqlState = this.readSqlState(driverException); + this.query = query; + + Object.defineProperty(this, "cause", { + configurable: true, + enumerable: false, + value: driverException, + writable: true, + }); + } + + public getSQLState(): string | null { + return this.sqlState ?? null; + } + + public getQuery(): Query | null { + return this.query; + } + + private readCode(error: Error): number | string | undefined { + const record = error as Error & { code?: unknown }; + return typeof record.code === "number" || typeof record.code === "string" + ? record.code + : undefined; + } + + private readSqlState(error: Error): string | undefined { + const record = error as Error & { + getSQLState?: () => string | null; + sqlState?: unknown; + sqlstate?: unknown; + }; + + if (typeof record.getSQLState === "function") { + const state = record.getSQLState(); + if (typeof state === "string") { + return state; + } + } + + if (typeof record.sqlState === "string") { + return record.sqlState; + } + + if (typeof record.sqlstate === "string") { + return record.sqlstate; + } + + return undefined; } } diff --git a/src/exception/driver-required-exception.ts b/src/exception/driver-required-exception.ts index 07e5af9..98c7e88 100644 --- a/src/exception/driver-required-exception.ts +++ b/src/exception/driver-required-exception.ts @@ -1,7 +1,3 @@ -import { DbalException } from "./dbal-exception"; +import { DriverRequired } from "./driver-required"; -export class DriverRequiredException extends DbalException { - constructor() { - super("Either `driver`, `driverClass`, or `driverInstance` must be provided."); - } -} +export class DriverRequiredException extends DriverRequired {} diff --git a/src/exception/driver-required.ts b/src/exception/driver-required.ts new file mode 100644 index 0000000..08927d3 --- /dev/null +++ b/src/exception/driver-required.ts @@ -0,0 +1,16 @@ +import { InvalidArgumentException } from "./invalid-argument-exception"; + +export class DriverRequired extends InvalidArgumentException { + public static new(url?: string | null): DriverRequired { + if (url != null) { + return new DriverRequired( + 'The options "driver" or "driverClass" are mandatory if a connection URL without scheme ' + + `is given to DriverManager::getConnection(). Given URL "${url}".`, + ); + } + + return new DriverRequired( + 'The options "driver" or "driverClass" are mandatory if no PDO instance is given to DriverManager::getConnection().', + ); + } +} diff --git a/src/exception/index.ts b/src/exception/index.ts deleted file mode 100644 index 5a03b7c..0000000 --- a/src/exception/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export { ConnectionException } from "./connection-exception"; -export { ConstraintViolationException } from "./constraint-violation-exception"; -export { DbalException } from "./dbal-exception"; -export { DeadlockException } from "./deadlock-exception"; -export { - DriverException, - type DriverExceptionDetails, -} from "./driver-exception"; -export { DriverRequiredException } from "./driver-required-exception"; -export { ForeignKeyConstraintViolationException } from "./foreign-key-constraint-violation-exception"; -export { InvalidParameterException } from "./invalid-parameter-exception"; -export { MalformedDsnException } from "./malformed-dsn-exception"; -export { MissingNamedParameterException } from "./missing-named-parameter-exception"; -export { MissingPositionalParameterException } from "./missing-positional-parameter-exception"; -export { MixedParameterStyleException } from "./mixed-parameter-style-exception"; -export { NestedTransactionsNotSupportedException } from "./nested-transactions-not-supported-exception"; -export { NoActiveTransactionException } from "./no-active-transaction-exception"; -export { NoKeyValueException } from "./no-key-value-exception"; -export { NotNullConstraintViolationException } from "./not-null-constraint-violation-exception"; -export { RollbackOnlyException } from "./rollback-only-exception"; -export { SqlSyntaxException } from "./sql-syntax-exception"; -export { UniqueConstraintViolationException } from "./unique-constraint-violation-exception"; -export { UnknownDriverException } from "./unknown-driver-exception"; diff --git a/src/exception/invalid-argument-exception.ts b/src/exception/invalid-argument-exception.ts new file mode 100644 index 0000000..e81d108 --- /dev/null +++ b/src/exception/invalid-argument-exception.ts @@ -0,0 +1,8 @@ +import { initializeException } from "./_util"; + +export class InvalidArgumentException extends Error { + constructor(message: string) { + super(message); + initializeException(this, new.target); + } +} diff --git a/src/exception/invalid-column-declaration.ts b/src/exception/invalid-column-declaration.ts new file mode 100644 index 0000000..36ae013 --- /dev/null +++ b/src/exception/invalid-column-declaration.ts @@ -0,0 +1,20 @@ +import { initializeException } from "./_util"; +import { InvalidColumnType } from "./invalid-column-type"; + +export class InvalidColumnDeclaration extends Error { + public static fromInvalidColumnType( + columnName: string, + error: InvalidColumnType, + ): InvalidColumnDeclaration { + return new InvalidColumnDeclaration(`Column "${columnName}" has invalid type`, error); + } + + constructor(message: string, cause?: unknown) { + super(message); + initializeException(this, new.target); + + if (cause !== undefined) { + (this as Error & { cause?: unknown }).cause = cause; + } + } +} diff --git a/src/exception/invalid-column-index.ts b/src/exception/invalid-column-index.ts new file mode 100644 index 0000000..5da6977 --- /dev/null +++ b/src/exception/invalid-column-index.ts @@ -0,0 +1,16 @@ +import { initializeException } from "./_util"; + +export class InvalidColumnIndex extends Error { + public static new(index: number, previous?: unknown): InvalidColumnIndex { + return new InvalidColumnIndex(`Invalid column index "${index}".`, previous); + } + + constructor(message: string, cause?: unknown) { + super(message); + initializeException(this, new.target); + + if (cause !== undefined) { + (this as Error & { cause?: unknown }).cause = cause; + } + } +} diff --git a/src/exception/invalid-column-type.ts b/src/exception/invalid-column-type.ts new file mode 100644 index 0000000..c86f0b3 --- /dev/null +++ b/src/exception/invalid-column-type.ts @@ -0,0 +1,8 @@ +import { initializeException } from "./_util"; + +export abstract class InvalidColumnType extends Error { + protected constructor(message: string) { + super(message); + initializeException(this, new.target); + } +} diff --git a/src/exception/invalid-column-type/column-length-required.ts b/src/exception/invalid-column-type/column-length-required.ts new file mode 100644 index 0000000..4e5ada2 --- /dev/null +++ b/src/exception/invalid-column-type/column-length-required.ts @@ -0,0 +1,14 @@ +import type { AbstractPlatform } from "../../platforms/abstract-platform"; +import { InvalidColumnType } from "../invalid-column-type"; + +export class ColumnLengthRequired extends InvalidColumnType { + public static new(platform: AbstractPlatform, type: string): ColumnLengthRequired { + return new ColumnLengthRequired( + `${ColumnLengthRequired.describePlatform(platform)} requires the length of a ${type} column to be specified`, + ); + } + + private static describePlatform(platform: AbstractPlatform): string { + return platform.constructor.name || "AbstractPlatform"; + } +} diff --git a/src/exception/invalid-column-type/column-precision-required.ts b/src/exception/invalid-column-type/column-precision-required.ts new file mode 100644 index 0000000..2be38c7 --- /dev/null +++ b/src/exception/invalid-column-type/column-precision-required.ts @@ -0,0 +1,7 @@ +import { InvalidColumnType } from "../invalid-column-type"; + +export class ColumnPrecisionRequired extends InvalidColumnType { + public static new(): ColumnPrecisionRequired { + return new ColumnPrecisionRequired("Column precision is not specified"); + } +} diff --git a/src/exception/invalid-column-type/column-scale-required.ts b/src/exception/invalid-column-type/column-scale-required.ts new file mode 100644 index 0000000..4f95425 --- /dev/null +++ b/src/exception/invalid-column-type/column-scale-required.ts @@ -0,0 +1,7 @@ +import { InvalidColumnType } from "../invalid-column-type"; + +export class ColumnScaleRequired extends InvalidColumnType { + public static new(): ColumnScaleRequired { + return new ColumnScaleRequired("Column scale is not specified"); + } +} diff --git a/src/exception/invalid-column-type/column-values-required.ts b/src/exception/invalid-column-type/column-values-required.ts new file mode 100644 index 0000000..1a01d6f --- /dev/null +++ b/src/exception/invalid-column-type/column-values-required.ts @@ -0,0 +1,14 @@ +import type { AbstractPlatform } from "../../platforms/abstract-platform"; +import { InvalidColumnType } from "../invalid-column-type"; + +export class ColumnValuesRequired extends InvalidColumnType { + public static new(platform: AbstractPlatform, type: string): ColumnValuesRequired { + return new ColumnValuesRequired( + `${ColumnValuesRequired.describePlatform(platform)} requires the values of a ${type} column to be specified`, + ); + } + + private static describePlatform(platform: AbstractPlatform): string { + return platform.constructor.name || "AbstractPlatform"; + } +} diff --git a/src/exception/invalid-driver-class.ts b/src/exception/invalid-driver-class.ts new file mode 100644 index 0000000..e815436 --- /dev/null +++ b/src/exception/invalid-driver-class.ts @@ -0,0 +1,9 @@ +import { InvalidArgumentException } from "./invalid-argument-exception"; + +export class InvalidDriverClass extends InvalidArgumentException { + public static new(driverClass: string): InvalidDriverClass { + return new InvalidDriverClass( + `The given driver class ${driverClass} has to implement the Driver interface.`, + ); + } +} diff --git a/src/exception/invalid-field-name-exception.ts b/src/exception/invalid-field-name-exception.ts new file mode 100644 index 0000000..34958da --- /dev/null +++ b/src/exception/invalid-field-name-exception.ts @@ -0,0 +1,3 @@ +import { DriverException } from "./driver-exception"; + +export class InvalidFieldNameException extends DriverException {} diff --git a/src/exception/invalid-parameter-exception.ts b/src/exception/invalid-parameter-exception.ts index b0c6968..eec87c2 100644 --- a/src/exception/invalid-parameter-exception.ts +++ b/src/exception/invalid-parameter-exception.ts @@ -1,3 +1,8 @@ -import { DbalException } from "./dbal-exception"; +import { initializeException } from "./_util"; -export class InvalidParameterException extends DbalException {} +export class InvalidParameterException extends Error { + constructor(message = "Invalid parameter.") { + super(message); + initializeException(this, new.target); + } +} diff --git a/src/exception/invalid-wrapper-class.ts b/src/exception/invalid-wrapper-class.ts new file mode 100644 index 0000000..05144fc --- /dev/null +++ b/src/exception/invalid-wrapper-class.ts @@ -0,0 +1,9 @@ +import { InvalidArgumentException } from "./invalid-argument-exception"; + +export class InvalidWrapperClass extends InvalidArgumentException { + public static new(wrapperClass: string): InvalidWrapperClass { + return new InvalidWrapperClass( + `The given wrapper class ${wrapperClass} has to be a subtype of Connection.`, + ); + } +} diff --git a/src/exception/lock-wait-timeout-exception.ts b/src/exception/lock-wait-timeout-exception.ts new file mode 100644 index 0000000..7af3a1a --- /dev/null +++ b/src/exception/lock-wait-timeout-exception.ts @@ -0,0 +1,4 @@ +import type { RetryableException } from "./retryable-exception"; +import { ServerException } from "./server-exception"; + +export class LockWaitTimeoutException extends ServerException implements RetryableException {} diff --git a/src/exception/malformed-dsn-exception.ts b/src/exception/malformed-dsn-exception.ts index 4e23f18..120917a 100644 --- a/src/exception/malformed-dsn-exception.ts +++ b/src/exception/malformed-dsn-exception.ts @@ -1,7 +1,8 @@ -import { DbalException } from "./dbal-exception"; +import { initializeException } from "./_util"; -export class MalformedDsnException extends DbalException { +export class MalformedDsnException extends Error { constructor(message = "Malformed database connection URL") { super(message); + initializeException(this, new.target); } } diff --git a/src/exception/missing-named-parameter-exception.ts b/src/exception/missing-named-parameter-exception.ts index fed2049..1fd1f93 100644 --- a/src/exception/missing-named-parameter-exception.ts +++ b/src/exception/missing-named-parameter-exception.ts @@ -1,7 +1,8 @@ -import { DbalException } from "./dbal-exception"; +import { initializeException } from "./_util"; -export class MissingNamedParameterException extends DbalException { +export class MissingNamedParameterException extends Error { constructor(name: string) { super(`Named parameter "${name}" does not have a bound value.`); + initializeException(this, new.target); } } diff --git a/src/exception/missing-positional-parameter-exception.ts b/src/exception/missing-positional-parameter-exception.ts index ad361e3..1eae078 100644 --- a/src/exception/missing-positional-parameter-exception.ts +++ b/src/exception/missing-positional-parameter-exception.ts @@ -1,7 +1,8 @@ -import { DbalException } from "./dbal-exception"; +import { initializeException } from "./_util"; -export class MissingPositionalParameterException extends DbalException { +export class MissingPositionalParameterException extends Error { constructor(index: number) { super(`Positional parameter at index ${index} does not have a bound value.`); + initializeException(this, new.target); } } diff --git a/src/exception/mixed-parameter-style-exception.ts b/src/exception/mixed-parameter-style-exception.ts deleted file mode 100644 index 57a9e73..0000000 --- a/src/exception/mixed-parameter-style-exception.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DbalException } from "./dbal-exception"; - -export class MixedParameterStyleException extends DbalException { - constructor() { - super("Mixing positional and named parameters is not supported."); - } -} diff --git a/src/exception/nested-transactions-not-supported-exception.ts b/src/exception/nested-transactions-not-supported-exception.ts index 83142a1..246d2cf 100644 --- a/src/exception/nested-transactions-not-supported-exception.ts +++ b/src/exception/nested-transactions-not-supported-exception.ts @@ -1,7 +1,3 @@ -import { DbalException } from "./dbal-exception"; +import { SavepointsNotSupported } from "./savepoints-not-supported"; -export class NestedTransactionsNotSupportedException extends DbalException { - constructor(driverName: string) { - super(`Driver "${driverName}" does not support nested transactions (savepoints).`); - } -} +export class NestedTransactionsNotSupportedException extends SavepointsNotSupported {} diff --git a/src/exception/no-active-transaction-exception.ts b/src/exception/no-active-transaction-exception.ts index 8f895a5..c0fc16b 100644 --- a/src/exception/no-active-transaction-exception.ts +++ b/src/exception/no-active-transaction-exception.ts @@ -1,7 +1,3 @@ -import { DbalException } from "./dbal-exception"; +import { NoActiveTransaction } from "./no-active-transaction"; -export class NoActiveTransactionException extends DbalException { - constructor() { - super("There is no active transaction."); - } -} +export class NoActiveTransactionException extends NoActiveTransaction {} diff --git a/src/exception/no-active-transaction.ts b/src/exception/no-active-transaction.ts new file mode 100644 index 0000000..76d439a --- /dev/null +++ b/src/exception/no-active-transaction.ts @@ -0,0 +1,12 @@ +import { ConnectionException } from "./connection-exception"; +import type { DriverExceptionDetails } from "./driver-exception"; + +export class NoActiveTransaction extends ConnectionException { + public static new(details?: Partial): NoActiveTransaction { + return new NoActiveTransaction("There is no active transaction.", { + driverName: "driver", + operation: "transaction", + ...details, + }); + } +} diff --git a/src/exception/no-key-value-exception.ts b/src/exception/no-key-value-exception.ts index f8d1055..94494ac 100644 --- a/src/exception/no-key-value-exception.ts +++ b/src/exception/no-key-value-exception.ts @@ -1,7 +1,3 @@ -import { DbalException } from "./dbal-exception"; +import { NoKeyValue } from "./no-key-value"; -export class NoKeyValueException extends DbalException { - constructor(columnCount: number) { - super(`Cannot build key/value result from ${columnCount} column(s). At least 2 are required.`); - } -} +export class NoKeyValueException extends NoKeyValue {} diff --git a/src/exception/no-key-value.ts b/src/exception/no-key-value.ts new file mode 100644 index 0000000..bcbacf5 --- /dev/null +++ b/src/exception/no-key-value.ts @@ -0,0 +1,14 @@ +import { initializeException } from "./_util"; + +export class NoKeyValue extends Error { + public static fromColumnCount(columnCount: number): NoKeyValue { + return new NoKeyValue( + `Fetching as key-value pairs requires the result to contain at least 2 columns, ${columnCount} given.`, + ); + } + + constructor(message: string) { + super(message); + initializeException(this, new.target); + } +} diff --git a/src/exception/non-unique-field-name-exception.ts b/src/exception/non-unique-field-name-exception.ts new file mode 100644 index 0000000..020ff91 --- /dev/null +++ b/src/exception/non-unique-field-name-exception.ts @@ -0,0 +1,3 @@ +import { ServerException } from "./server-exception"; + +export class NonUniqueFieldNameException extends ServerException {} diff --git a/src/exception/parse-error.ts b/src/exception/parse-error.ts new file mode 100644 index 0000000..6ba95f0 --- /dev/null +++ b/src/exception/parse-error.ts @@ -0,0 +1,17 @@ +import type { ParserException } from "../sql/parser"; +import { initializeException } from "./_util"; + +export class ParseError extends Error { + public static fromParserException(exception: ParserException): ParseError { + return new ParseError("Unable to parse query.", exception); + } + + constructor(message: string, cause?: unknown) { + super(message); + initializeException(this, new.target); + + if (cause !== undefined) { + (this as Error & { cause?: unknown }).cause = cause; + } + } +} diff --git a/src/exception/read-only-exception.ts b/src/exception/read-only-exception.ts new file mode 100644 index 0000000..0b8b8c1 --- /dev/null +++ b/src/exception/read-only-exception.ts @@ -0,0 +1,3 @@ +import { ServerException } from "./server-exception"; + +export class ReadOnlyException extends ServerException {} diff --git a/src/exception/retryable-exception.ts b/src/exception/retryable-exception.ts new file mode 100644 index 0000000..ca6dc5a --- /dev/null +++ b/src/exception/retryable-exception.ts @@ -0,0 +1 @@ +export type RetryableException = object; diff --git a/src/exception/rollback-only-exception.ts b/src/exception/rollback-only-exception.ts index 4838803..bb3e0ad 100644 --- a/src/exception/rollback-only-exception.ts +++ b/src/exception/rollback-only-exception.ts @@ -1,7 +1,3 @@ -import { DbalException } from "./dbal-exception"; +import { CommitFailedRollbackOnly } from "./commit-failed-rollback-only"; -export class RollbackOnlyException extends DbalException { - constructor() { - super("The current transaction is marked rollback-only and cannot be committed."); - } -} +export class RollbackOnlyException extends CommitFailedRollbackOnly {} diff --git a/src/exception/savepoints-not-supported.ts b/src/exception/savepoints-not-supported.ts new file mode 100644 index 0000000..02fba22 --- /dev/null +++ b/src/exception/savepoints-not-supported.ts @@ -0,0 +1,12 @@ +import { ConnectionException } from "./connection-exception"; +import type { DriverExceptionDetails } from "./driver-exception"; + +export class SavepointsNotSupported extends ConnectionException { + public static new(details?: Partial): SavepointsNotSupported { + return new SavepointsNotSupported("Savepoints are not supported by this driver.", { + driverName: "driver", + operation: "savepoint", + ...details, + }); + } +} diff --git a/src/exception/schema-does-not-exist.ts b/src/exception/schema-does-not-exist.ts new file mode 100644 index 0000000..985114b --- /dev/null +++ b/src/exception/schema-does-not-exist.ts @@ -0,0 +1,3 @@ +import { DatabaseObjectNotFoundException } from "./database-object-not-found-exception"; + +export class SchemaDoesNotExist extends DatabaseObjectNotFoundException {} diff --git a/src/exception/server-exception.ts b/src/exception/server-exception.ts new file mode 100644 index 0000000..8c9fdea --- /dev/null +++ b/src/exception/server-exception.ts @@ -0,0 +1,3 @@ +import { DriverException } from "./driver-exception"; + +export class ServerException extends DriverException {} diff --git a/src/exception/sql-syntax-exception.ts b/src/exception/sql-syntax-exception.ts deleted file mode 100644 index 0f7c027..0000000 --- a/src/exception/sql-syntax-exception.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DriverException } from "./driver-exception"; - -export class SqlSyntaxException extends DriverException {} diff --git a/src/exception/syntax-error-exception.ts b/src/exception/syntax-error-exception.ts new file mode 100644 index 0000000..dcc185f --- /dev/null +++ b/src/exception/syntax-error-exception.ts @@ -0,0 +1,3 @@ +import { ServerException } from "./server-exception"; + +export class SyntaxErrorException extends ServerException {} diff --git a/src/exception/table-exists-exception.ts b/src/exception/table-exists-exception.ts new file mode 100644 index 0000000..53dd699 --- /dev/null +++ b/src/exception/table-exists-exception.ts @@ -0,0 +1,3 @@ +import { DriverException } from "./driver-exception"; + +export class TableExistsException extends DriverException {} diff --git a/src/exception/table-not-found-exception.ts b/src/exception/table-not-found-exception.ts new file mode 100644 index 0000000..612ae96 --- /dev/null +++ b/src/exception/table-not-found-exception.ts @@ -0,0 +1,3 @@ +import { DriverException } from "./driver-exception"; + +export class TableNotFoundException extends DriverException {} diff --git a/src/exception/transaction-rolled-back.ts b/src/exception/transaction-rolled-back.ts new file mode 100644 index 0000000..f58c3c3 --- /dev/null +++ b/src/exception/transaction-rolled-back.ts @@ -0,0 +1,3 @@ +import { ServerException } from "./server-exception"; + +export class TransactionRolledBack extends ServerException {} diff --git a/src/exception/unknown-driver-exception.ts b/src/exception/unknown-driver-exception.ts index c6a83a4..6643625 100644 --- a/src/exception/unknown-driver-exception.ts +++ b/src/exception/unknown-driver-exception.ts @@ -1,9 +1,3 @@ -import { DbalException } from "./dbal-exception"; +import { UnknownDriver } from "./unknown-driver"; -export class UnknownDriverException extends DbalException { - constructor(driver: string, availableDrivers: string[]) { - super( - `Unknown driver "${driver}". Available drivers: ${availableDrivers.join(", ") || "none"}.`, - ); - } -} +export class UnknownDriverException extends UnknownDriver {} diff --git a/src/exception/unknown-driver.ts b/src/exception/unknown-driver.ts new file mode 100644 index 0000000..e156e43 --- /dev/null +++ b/src/exception/unknown-driver.ts @@ -0,0 +1,9 @@ +import { InvalidArgumentException } from "./invalid-argument-exception"; + +export class UnknownDriver extends InvalidArgumentException { + public static new(unknownDriverName: string, knownDrivers: string[]): UnknownDriver { + return new UnknownDriver( + `The given driver "${unknownDriverName}" is unknown, Doctrine currently supports only the following drivers: ${knownDrivers.join(", ")}`, + ); + } +} diff --git a/src/expand-array-parameters.ts b/src/expand-array-parameters.ts index a31835e..20be120 100644 --- a/src/expand-array-parameters.ts +++ b/src/expand-array-parameters.ts @@ -1,24 +1,16 @@ import { ArrayParameterType } from "./array-parameter-type"; -import { - InvalidParameterException, - MissingNamedParameterException, - MissingPositionalParameterException, - MixedParameterStyleException, -} from "./exception/index"; -import { ParameterType } from "./parameter-type"; -import type { Visitor } from "./sql/parser"; +import { MissingNamedParameter } from "./array-parameters/exception/missing-named-parameter"; +import { MissingPositionalParameter } from "./array-parameters/exception/missing-positional-parameter"; import type { QueryParameterType, QueryParameterTypes, QueryParameters, QueryScalarParameterType, -} from "./types"; - -type ParameterStyle = "none" | "named" | "positional"; +} from "./query"; +import type { Visitor } from "./sql/parser"; export class ExpandArrayParameters implements Visitor { - private originalParameterIndex = 0; - private parameterStyle: ParameterStyle = "none"; + private originalParameterIndex: number = 0; private readonly convertedSQL: string[] = []; private readonly convertedParameters: unknown[] = []; private readonly convertedTypes: QueryScalarParameterType[] = []; @@ -29,31 +21,22 @@ export class ExpandArrayParameters implements Visitor { ) {} public acceptPositionalParameter(_sql: string): void { - this.acceptParameterStyle("positional"); - - if (!Array.isArray(this.parameters)) { - throw new MixedParameterStyleException(); - } - - const index = this.originalParameterIndex; + const index: number = this.originalParameterIndex; if (!Object.hasOwn(this.parameters, index)) { - throw new MissingPositionalParameterException(index); + throw MissingPositionalParameter.new(index); } - this.acceptParameter(index, this.parameters[index]); + this.acceptParameter(index, (this.parameters as unknown[])[index]); this.originalParameterIndex += 1; } public acceptNamedParameter(sql: string): void { - this.acceptParameterStyle("named"); - - if (Array.isArray(this.parameters)) { - throw new MixedParameterStyleException(); + const name = sql.slice(1); + if (!Object.hasOwn(this.parameters, name)) { + throw MissingNamedParameter.new(name); } - const name = sql.slice(1); - const value = this.readNamedValue(name); - this.acceptParameter(name, value); + this.acceptParameter(name, (this.parameters as Record)[name]); } public acceptOther(sql: string): void { @@ -73,40 +56,25 @@ export class ExpandArrayParameters implements Visitor { } private acceptParameter(key: number | string, value: unknown): void { - const type = this.readType(key) ?? ParameterType.STRING; - - if (!Array.isArray(value)) { - this.appendTypedParameter([value], type); + const type = this.readType(key); + if (type === undefined) { + this.convertedSQL.push("?"); + this.convertedParameters.push(value); return; } if (!this.isArrayParameterType(type)) { - throw new InvalidParameterException("Array values require an ArrayParameterType binding."); + this.appendTypedParameter([value], type); + return; } - if (value.length === 0) { + const values = value as unknown[]; + if (values.length === 0) { this.convertedSQL.push("NULL"); return; } - this.appendTypedParameter(value, ArrayParameterType.toElementParameterType(type)); - } - - private readNamedValue(name: string): unknown { - if (Array.isArray(this.parameters)) { - throw new MixedParameterStyleException(); - } - - if (Object.hasOwn(this.parameters, name)) { - return this.parameters[name]; - } - - const prefixedName = `:${name}`; - if (Object.hasOwn(this.parameters, prefixedName)) { - return this.parameters[prefixedName]; - } - - throw new MissingNamedParameterException(name); + this.appendTypedParameter(values, ArrayParameterType.toElementParameterType(type)); } private readType(key: number | string): QueryParameterType | undefined { @@ -118,37 +86,21 @@ export class ExpandArrayParameters implements Visitor { return this.types[key]; } - if (typeof key === "string") { - if (Object.hasOwn(this.types, key)) { - return this.types[key]; - } - - const prefixedName = `:${key}`; - if (Object.hasOwn(this.types, prefixedName)) { - return this.types[prefixedName]; - } + if (!Object.hasOwn(this.types, key)) { + return undefined; } - return undefined; + return (this.types as Record)[String(key)]; } private appendTypedParameter(values: unknown[], type: QueryScalarParameterType): void { this.convertedSQL.push(new Array(values.length).fill("?").join(", ")); + let index = this.convertedParameters.length; for (const value of values) { this.convertedParameters.push(value); - this.convertedTypes.push(type); - } - } - - private acceptParameterStyle(nextStyle: ParameterStyle): void { - if (this.parameterStyle === "none") { - this.parameterStyle = nextStyle; - return; - } - - if (this.parameterStyle !== nextStyle) { - throw new MixedParameterStyleException(); + this.convertedTypes[index] = type; + index += 1; } } diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index a3700cd..0000000 --- a/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { ArrayParameterType } from "./array-parameter-type"; -export { ColumnCase } from "./column-case"; -export { Configuration } from "./configuration"; -export { Connection } from "./connection"; -export type { - Driver, - DriverConnection, - DriverExecutionResult, - DriverMiddleware, - DriverQueryResult, -} from "./driver"; -export { ParameterBindingStyle } from "./driver"; -export { DriverManager } from "./driver-manager"; -export type { Exception } from "./exception"; -export { ExpandArrayParameters } from "./expand-array-parameters"; -export { LockMode } from "./lock-mode"; -export { ParameterType } from "./parameter-type"; -export { Query } from "./query"; -export { Result } from "./result"; -export type { ServerVersionProvider } from "./server-version-provider"; -export { Statement } from "./statement"; -export { StaticServerVersionProvider } from "./static-server-version-provider"; -export { TransactionIsolationLevel } from "./transaction-isolation-level"; -export type { - CompiledQuery, - QueryParameterType, - QueryParameterTypes, - QueryParameters, - QueryScalarParameterType, -} from "./types"; diff --git a/src/logging/connection.ts b/src/logging/connection.ts index 7074ffb..e8a0730 100644 --- a/src/logging/connection.ts +++ b/src/logging/connection.ts @@ -1,61 +1,36 @@ -import type { DriverConnection, DriverExecutionResult, DriverQueryResult } from "../driver"; -import type { CompiledQuery } from "../types"; +import type { Connection as DriverConnection } from "../driver/connection"; import type { Logger } from "./logger"; +import { DriverStatementWrapper } from "./statement"; export class Connection implements DriverConnection { - public readonly createSavepoint?: (name: string) => Promise; - public readonly releaseSavepoint?: (name: string) => Promise; - public readonly rollbackSavepoint?: (name: string) => Promise; - public readonly quote?: (value: string) => string; - constructor( private readonly connection: DriverConnection, private readonly logger: Logger, - ) { - if (this.connection.createSavepoint !== undefined) { - this.createSavepoint = async (name: string): Promise => { - this.logger.debug("Creating savepoint {name}", { name }); - await this.connection.createSavepoint?.(name); - }; - } - - if (this.connection.releaseSavepoint !== undefined) { - this.releaseSavepoint = async (name: string): Promise => { - this.logger.debug("Releasing savepoint {name}", { name }); - await this.connection.releaseSavepoint?.(name); - }; - } + ) {} - if (this.connection.rollbackSavepoint !== undefined) { - this.rollbackSavepoint = async (name: string): Promise => { - this.logger.debug("Rolling back savepoint {name}", { name }); - await this.connection.rollbackSavepoint?.(name); - }; - } + public async prepare(sql: string): Promise>> { + this.logger.debug("Preparing statement: {sql}", { sql }); - if (this.connection.quote !== undefined) { - this.quote = (value: string): string => this.connection.quote!(value); - } + const statement = await this.connection.prepare(sql); + return new DriverStatementWrapper(statement, this.logger, sql); } - public async executeQuery(query: CompiledQuery): Promise { - this.logger.debug("Executing query: {sql} (parameters: {params}, types: {types})", { - params: query.parameters, - sql: query.sql, - types: query.types, - }); + public async query(sql: string): Promise>> { + this.logger.debug("Executing query: {sql}", { sql }); + return this.connection.query(sql); + } - return this.connection.executeQuery(query); + public quote(value: string): string { + return this.connection.quote(value); } - public async executeStatement(query: CompiledQuery): Promise { - this.logger.debug("Executing statement: {sql} (parameters: {params}, types: {types})", { - params: query.parameters, - sql: query.sql, - types: query.types, - }); + public async exec(sql: string): Promise { + this.logger.debug("Executing statement: {sql}", { sql }); + return this.connection.exec(sql); + } - return this.connection.executeStatement(query); + public async lastInsertId(): Promise { + return this.connection.lastInsertId(); } public async beginTransaction(): Promise { @@ -79,7 +54,8 @@ export class Connection implements DriverConnection { public async close(): Promise { this.logger.info("Disconnecting"); - await this.connection.close(); + const closable = this.connection as DriverConnection & { close?: () => Promise }; + await closable.close?.(); } public getNativeConnection(): unknown { diff --git a/src/logging/console-logger.ts b/src/logging/console-logger.ts deleted file mode 100644 index 85f0dc7..0000000 --- a/src/logging/console-logger.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Logger } from "./logger"; - -export class ConsoleLogger implements Logger { - constructor( - private readonly output: Pick = console, - ) {} - - public debug(message: string, context?: Record): void { - if (context === undefined) { - this.output.debug(message); - return; - } - - this.output.debug(message, context); - } - - public info(message: string, context?: Record): void { - if (context === undefined) { - this.output.info(message); - return; - } - - this.output.info(message, context); - } - - public warn(message: string, context?: Record): void { - if (context === undefined) { - this.output.warn(message); - return; - } - - this.output.warn(message, context); - } - - public error(message: string, context?: Record): void { - if (context === undefined) { - this.output.error(message); - return; - } - - this.output.error(message, context); - } -} diff --git a/src/logging/driver.ts b/src/logging/driver.ts index 1018698..5e9223e 100644 --- a/src/logging/driver.ts +++ b/src/logging/driver.ts @@ -1,8 +1,4 @@ -import { - type DriverConnection, - type Driver as DriverInterface, - ParameterBindingStyle, -} from "../driver"; +import { type DriverConnection, type Driver as DriverInterface } from "../driver"; import type { ExceptionConverter } from "../driver/api/exception-converter"; import type { AbstractPlatform } from "../platforms/abstract-platform"; import type { ServerVersionProvider } from "../server-version-provider"; @@ -20,14 +16,6 @@ export class Driver implements DriverInterface { this.driver.getDatabasePlatform(versionProvider); } - public get name(): string { - return this.driver.name; - } - - public get bindingStyle(): ParameterBindingStyle { - return this.driver.bindingStyle; - } - public async connect(params: Record): Promise { this.logger.info("Connecting with parameters {params}", { params: this.maskPassword(params), diff --git a/src/logging/index.ts b/src/logging/index.ts deleted file mode 100644 index 206e706..0000000 --- a/src/logging/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { Connection } from "./connection"; -export { ConsoleLogger } from "./console-logger"; -export { Driver } from "./driver"; -export type { Logger } from "./logger"; -export { Middleware } from "./middleware"; diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 1b6c123..ae11c82 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -4,3 +4,45 @@ export interface Logger { warn(message: string, context?: Record): void; error(message: string, context?: Record): void; } + +export class ConsoleLogger implements Logger { + constructor( + private readonly output: Pick = console, + ) {} + + public debug(message: string, context?: Record): void { + if (context === undefined) { + this.output.debug(message); + return; + } + + this.output.debug(message, context); + } + + public info(message: string, context?: Record): void { + if (context === undefined) { + this.output.info(message); + return; + } + + this.output.info(message, context); + } + + public warn(message: string, context?: Record): void { + if (context === undefined) { + this.output.warn(message); + return; + } + + this.output.warn(message, context); + } + + public error(message: string, context?: Record): void { + if (context === undefined) { + this.output.error(message); + return; + } + + this.output.error(message, context); + } +} diff --git a/src/logging/middleware.ts b/src/logging/middleware.ts index 7a88b48..809f47d 100644 --- a/src/logging/middleware.ts +++ b/src/logging/middleware.ts @@ -1,12 +1,12 @@ -import type { Driver as DriverInterface, DriverMiddleware } from "../driver"; -import { ConsoleLogger } from "./console-logger"; +import type { Middleware as DriverMiddleware } from "../driver/middleware"; import { Driver } from "./driver"; import type { Logger } from "./logger"; +import { ConsoleLogger } from "./logger"; export class Middleware implements DriverMiddleware { constructor(private readonly logger: Logger = new ConsoleLogger()) {} - public wrap(driver: DriverInterface): DriverInterface { + public wrap(driver: Driver): Driver { return new Driver(driver, this.logger); } } diff --git a/src/logging/statement.ts b/src/logging/statement.ts new file mode 100644 index 0000000..8d2d1f7 --- /dev/null +++ b/src/logging/statement.ts @@ -0,0 +1,58 @@ +import type { Result as DriverResult } from "../driver/result"; +import type { Statement as DriverStatement } from "../driver/statement"; +import { ParameterType } from "../parameter-type"; +import type { Logger } from "./logger"; + +type BoundParameters = unknown[] | Record; +type BoundTypes = unknown[] | Record; + +export class DriverStatementWrapper implements DriverStatement { + private params: BoundParameters = []; + private types: BoundTypes = []; + + constructor( + private readonly statement: DriverStatement, + private readonly logger: Logger, + private readonly sql: string, + ) {} + + public bindValue( + param: string | number, + value: unknown, + type: ParameterType = ParameterType.STRING, + ): void { + if (typeof param === "number") { + if (!Array.isArray(this.params)) { + this.params = []; + this.types = []; + } + + this.params[param - 1] = value; + (this.types as unknown[])[param - 1] = type; + } else { + if (Array.isArray(this.params)) { + this.params = {}; + this.types = {}; + } + + const key = param.startsWith(":") || param.startsWith("@") ? param.slice(1) : param; + (this.params as Record)[key] = value; + (this.types as Record)[key] = type; + } + + this.statement.bindValue(param, value, type); + } + + public async execute(): Promise { + this.logger.debug( + "Executing prepared statement: {sql} (parameters: {params}, types: {types})", + { + params: this.params, + sql: this.sql, + types: this.types, + }, + ); + + return this.statement.execute(); + } +} diff --git a/src/platforms/db2/.gitkeep b/src/platforms/db2/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/index.ts b/src/platforms/index.ts deleted file mode 100644 index 27ed04a..0000000 --- a/src/platforms/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -export { AbstractMySQLPlatform } from "./abstract-mysql-platform"; -export { AbstractPlatform } from "./abstract-platform"; -export { DateIntervalUnit } from "./date-interval-unit"; -export { DB2Platform } from "./db2-platform"; -export { - DB2Keywords, - EmptyKeywords, - KeywordList, - MariaDB117Keywords, - MariaDBKeywords, - MySQL80Keywords, - MySQL84Keywords, - MySQLKeywords, - OracleKeywords, - PostgreSQLKeywords, - SQLServerKeywords, - SQLiteKeywords, -} from "./keywords"; -export { MariaDBPlatform } from "./mariadb-platform"; -export { MariaDB1010Platform } from "./mariadb1010-platform"; -export { MariaDB1052Platform } from "./mariadb1052-platform"; -export { MariaDB1060Platform } from "./mariadb1060-platform"; -export { MariaDB110700Platform } from "./mariadb110700-platform"; -export { MySQLPlatform } from "./mysql-platform"; -export { MySQL80Platform } from "./mysql80-platform"; -export { MySQL84Platform } from "./mysql84-platform"; -export { OraclePlatform } from "./oracle-platform"; -export { PostgreSQLPlatform } from "./postgre-sql-platform"; -export { PostgreSQL120Platform } from "./postgre-sql120-platform"; -export { SQLServerPlatform } from "./sql-server-platform"; -export { SQLitePlatform } from "./sqlite-platform"; -export { TrimMode } from "./trim-mode"; diff --git a/src/platforms/my-sql/charset-metadata-provider/.gitkeep b/src/platforms/my-sql/charset-metadata-provider/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/my-sql/collation-metadata-provider/.gitkeep b/src/platforms/my-sql/collation-metadata-provider/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/mysql/charset-metadata-provider/.gitkeep b/src/platforms/mysql/charset-metadata-provider/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/mysql/collation-metadata-provider/.gitkeep b/src/platforms/mysql/collation-metadata-provider/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/oracle/.gitkeep b/src/platforms/oracle/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/postgre-sql/.gitkeep b/src/platforms/postgre-sql/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/postgresql/.gitkeep b/src/platforms/postgresql/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/sq-lite/sq-lite-metadata-provider/.gitkeep b/src/platforms/sq-lite/sq-lite-metadata-provider/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/sql-server/sql/builder/.gitkeep b/src/platforms/sql-server/sql/builder/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/sqlite/sqlite-metadata-provider/.gitkeep b/src/platforms/sqlite/sqlite-metadata-provider/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/platforms/sqlserver/sql/builder/.gitkeep b/src/platforms/sqlserver/sql/builder/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/portability/connection.ts b/src/portability/connection.ts index 88621ae..30d17f9 100644 --- a/src/portability/connection.ts +++ b/src/portability/connection.ts @@ -1,7 +1,7 @@ -import type { DriverConnection, DriverExecutionResult, DriverQueryResult } from "../driver"; -import type { CompiledQuery } from "../types"; +import type { DriverConnection } from "../driver"; import { Converter } from "./converter"; import { Result } from "./result"; +import { DriverStatementWrapper } from "./statement"; export class Connection implements DriverConnection { public static readonly PORTABILITY_ALL = 255; @@ -10,42 +10,31 @@ export class Connection implements DriverConnection { public static readonly PORTABILITY_EMPTY_TO_NULL = 4; public static readonly PORTABILITY_FIX_CASE = 8; - public readonly createSavepoint?: (name: string) => Promise; - public readonly releaseSavepoint?: (name: string) => Promise; - public readonly rollbackSavepoint?: (name: string) => Promise; - public readonly quote?: (value: string) => string; - constructor( private readonly connection: DriverConnection, private readonly converter: Converter, - ) { - if (this.connection.createSavepoint !== undefined) { - this.createSavepoint = async (name: string): Promise => - this.connection.createSavepoint?.(name); - } + ) {} - if (this.connection.releaseSavepoint !== undefined) { - this.releaseSavepoint = async (name: string): Promise => - this.connection.releaseSavepoint?.(name); - } + public async prepare(sql: string): Promise>> { + const statement = await this.connection.prepare(sql); + return new DriverStatementWrapper(statement, this.converter); + } - if (this.connection.rollbackSavepoint !== undefined) { - this.rollbackSavepoint = async (name: string): Promise => - this.connection.rollbackSavepoint?.(name); - } + public async query(sql: string): Promise>> { + const result = await this.connection.query(sql); + return new Result(result, this.converter); + } - if (this.connection.quote !== undefined) { - this.quote = (value: string): string => this.connection.quote!(value); - } + public quote(value: string): string { + return this.connection.quote(value); } - public async executeQuery(query: CompiledQuery): Promise { - const result = await this.connection.executeQuery(query); - return new Result(result, this.converter).toDriverQueryResult(); + public async exec(sql: string): Promise { + return this.connection.exec(sql); } - public async executeStatement(query: CompiledQuery): Promise { - return this.connection.executeStatement(query); + public async lastInsertId(): Promise { + return this.connection.lastInsertId(); } public async beginTransaction(): Promise { @@ -65,7 +54,8 @@ export class Connection implements DriverConnection { } public async close(): Promise { - await this.connection.close(); + const closable = this.connection as DriverConnection & { close?: () => Promise }; + await closable.close?.(); } public getNativeConnection(): unknown { diff --git a/src/portability/converter.ts b/src/portability/converter.ts index 70e458e..fa9823b 100644 --- a/src/portability/converter.ts +++ b/src/portability/converter.ts @@ -1,5 +1,4 @@ import { ColumnCase } from "../column-case"; -import type { DriverQueryResult } from "../driver"; export class Converter { constructor( @@ -8,25 +7,7 @@ export class Converter { private readonly columnCase: ColumnCase | null, ) {} - public convertQueryResult(result: DriverQueryResult): DriverQueryResult { - const rows = - this.columnCase === null && !this.convertEmptyStringToNull && !this.rightTrimString - ? result.rows - : result.rows.map((row) => this.convertRow(row)); - - const columns = - result.columns === undefined - ? undefined - : result.columns.map((name) => this.convertColumnName(name)); - - return { - columns, - rowCount: result.rowCount, - rows, - }; - } - - private convertRow(row: Record): Record { + public convertRow(row: Record): Record { if (!this.convertEmptyStringToNull && !this.rightTrimString && this.columnCase === null) { return row; } @@ -40,7 +21,7 @@ export class Converter { return converted; } - private convertColumnName(name: string): string { + public convertColumnName(name: string): string { if (this.columnCase === ColumnCase.LOWER) { return name.toLowerCase(); } @@ -52,7 +33,7 @@ export class Converter { return name; } - private convertValue(value: unknown): unknown { + public convertValue(value: unknown): unknown { if (this.convertEmptyStringToNull && value === "") { return null; } diff --git a/src/portability/driver.ts b/src/portability/driver.ts index 0a1abf6..63218b6 100644 --- a/src/portability/driver.ts +++ b/src/portability/driver.ts @@ -1,9 +1,5 @@ import { ColumnCase } from "../column-case"; -import { - type DriverConnection, - type Driver as DriverInterface, - ParameterBindingStyle, -} from "../driver"; +import { type DriverConnection, type Driver as DriverInterface } from "../driver"; import type { ExceptionConverter } from "../driver/api/exception-converter"; import type { AbstractPlatform } from "../platforms/abstract-platform"; import type { ServerVersionProvider } from "../server-version-provider"; @@ -24,14 +20,6 @@ export class Driver implements DriverInterface { this.driver.getDatabasePlatform(versionProvider); } - public get name(): string { - return this.driver.name; - } - - public get bindingStyle(): ParameterBindingStyle { - return this.driver.bindingStyle; - } - public async connect(params: Record): Promise { const connection = await this.driver.connect(params); let portability = this.mode; diff --git a/src/portability/index.ts b/src/portability/index.ts deleted file mode 100644 index cb627a5..0000000 --- a/src/portability/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { Connection } from "./connection"; -export { Converter } from "./converter"; -export { Driver } from "./driver"; -export { Middleware } from "./middleware"; -export { OptimizeFlags } from "./optimize-flags"; -export { Result } from "./result"; diff --git a/src/portability/result.ts b/src/portability/result.ts index 96ecf06..197259f 100644 --- a/src/portability/result.ts +++ b/src/portability/result.ts @@ -1,13 +1,84 @@ -import type { DriverQueryResult } from "../driver"; +import { FetchUtils } from "../driver/fetch-utils"; +import type { Result as DriverResult } from "../driver/result"; import { Converter } from "./converter"; -export class Result { +type DriverResultWithColumnName = DriverResult & { + getColumnName?: (index: number) => string; +}; + +export class Result implements DriverResult { constructor( - private readonly result: DriverQueryResult, + private readonly result: DriverResult, private readonly converter: Converter, ) {} - public toDriverQueryResult(): DriverQueryResult { - return this.converter.convertQueryResult(this.result); + public fetchNumeric(): T[] | false { + const row = this.result.fetchNumeric(); + if (row === false) { + return false; + } + + return row.map((value) => this.converter.convertValue(value)) as T[]; + } + + public fetchAssociative = Record>(): + | T + | false { + const row = this.result.fetchAssociative>(); + if (row === false) { + return false; + } + + return this.converter.convertRow(row) as T; + } + + public fetchOne(): T | false { + const value = this.result.fetchOne(); + if (value === false) { + return false; + } + + return this.converter.convertValue(value) as T; + } + + public fetchAllNumeric(): T[][] { + return FetchUtils.fetchAllNumeric(this); + } + + public fetchAllAssociative = Record>(): T[] { + const rows: T[] = []; + let row = this.fetchAssociative(); + + while (row !== false) { + rows.push(row); + row = this.fetchAssociative(); + } + + return rows; + } + + public fetchFirstColumn(): T[] { + return FetchUtils.fetchFirstColumn(this); + } + + public rowCount(): number | string { + return this.result.rowCount(); + } + + public columnCount(): number { + return this.result.columnCount(); + } + + public getColumnName(index: number): string { + const resultWithColumnName = this.result as DriverResultWithColumnName; + if (typeof resultWithColumnName.getColumnName !== "function") { + throw new Error("The driver result does not support accessing the column name."); + } + + return this.converter.convertColumnName(resultWithColumnName.getColumnName(index)); + } + + public free(): void { + this.result.free(); } } diff --git a/src/portability/statement.ts b/src/portability/statement.ts new file mode 100644 index 0000000..c162963 --- /dev/null +++ b/src/portability/statement.ts @@ -0,0 +1,25 @@ +import type { Result as DriverResult } from "../driver/result"; +import type { Statement as DriverStatement } from "../driver/statement"; +import { ParameterType } from "../parameter-type"; +import { Converter } from "./converter"; +import { Result } from "./result"; + +export class DriverStatementWrapper implements DriverStatement { + constructor( + private readonly statement: DriverStatement, + private readonly converter: Converter, + ) {} + + public bindValue( + param: string | number, + value: unknown, + type: ParameterType = ParameterType.STRING, + ): void { + this.statement.bindValue(param, value, type); + } + + public async execute(): Promise { + const result = await this.statement.execute(); + return new Result(result, this.converter); + } +} diff --git a/src/query.ts b/src/query.ts index 24bea0d..1b3d006 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,4 +1,11 @@ -import type { QueryParameterTypes, QueryParameters } from "./types"; +import { ArrayParameterType } from "./array-parameter-type"; +import { ParameterType } from "./parameter-type"; +import type { Type } from "./types/type"; + +export type QueryScalarParameterType = ParameterType | string | Type; +export type QueryParameterType = QueryScalarParameterType | ArrayParameterType; +export type QueryParameters = unknown[] | Record; +export type QueryParameterTypes = QueryParameterType[] | Record; export class Query { constructor( @@ -7,3 +14,9 @@ export class Query { public readonly types: QueryParameterTypes = [], ) {} } + +export interface CompiledQuery { + sql: string; + parameters: QueryParameters; + types: QueryScalarParameterType[] | Record; +} diff --git a/src/query/for-update.ts b/src/query/for-update.ts index 684c46a..ba22ffc 100644 --- a/src/query/for-update.ts +++ b/src/query/for-update.ts @@ -1,7 +1,4 @@ -export enum ConflictResolutionMode { - ORDINARY, - SKIP_LOCKED, -} +import { ConflictResolutionMode } from "./for-update/conflict-resolution-mode"; export class ForUpdate { constructor(public readonly conflictResolutionMode: ConflictResolutionMode) {} diff --git a/src/query/for-update/conflict-resolution-mode.ts b/src/query/for-update/conflict-resolution-mode.ts new file mode 100644 index 0000000..53071a7 --- /dev/null +++ b/src/query/for-update/conflict-resolution-mode.ts @@ -0,0 +1,4 @@ +export enum ConflictResolutionMode { + ORDINARY, + SKIP_LOCKED, +} diff --git a/src/query/index.ts b/src/query/index.ts deleted file mode 100644 index 4bc7e67..0000000 --- a/src/query/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { Query } from "../query"; -export { CommonTableExpression } from "./common-table-expression"; -export { NonUniqueAlias } from "./exception/non-unique-alias"; -export { UnknownAlias } from "./exception/unknown-alias"; -export { CompositeExpression } from "./expression/composite-expression"; -export { ExpressionBuilder } from "./expression/expression-builder"; -export { ConflictResolutionMode, ForUpdate } from "./for-update"; -export { From } from "./from"; -export { Join } from "./join"; -export { Limit } from "./limit"; -export { PlaceHolder, QueryBuilder } from "./query-builder"; -export { QueryException } from "./query-exception"; -export { QueryType } from "./query-type"; -export { SelectQuery } from "./select-query"; -export { Union } from "./union"; -export { UnionQuery } from "./union-query"; -export { UnionType } from "./union-type"; diff --git a/src/query/query-builder.ts b/src/query/query-builder.ts index 17e0fe0..f425790 100644 --- a/src/query/query-builder.ts +++ b/src/query/query-builder.ts @@ -1,14 +1,15 @@ import { ArrayParameterType } from "../array-parameter-type"; import type { Connection } from "../connection"; import { ParameterType } from "../parameter-type"; +import type { QueryParameterTypes, QueryParameters } from "../query"; import { CommonTableExpression } from "../query/common-table-expression"; import type { Result } from "../result"; -import type { QueryParameterTypes, QueryParameters } from "../types"; import { NonUniqueAlias } from "./exception/non-unique-alias"; import { UnknownAlias } from "./exception/unknown-alias"; import { CompositeExpression } from "./expression/composite-expression"; import { ExpressionBuilder } from "./expression/expression-builder"; -import { ConflictResolutionMode, ForUpdate } from "./for-update"; +import { ForUpdate } from "./for-update"; +import { ConflictResolutionMode } from "./for-update/conflict-resolution-mode"; import { From } from "./from"; import { Join } from "./join"; import { Limit } from "./limit"; @@ -19,7 +20,7 @@ import { Union } from "./union"; import { UnionQuery } from "./union-query"; import { UnionType } from "./union-type"; -type DataMap = Record; +type AssociativeRow = Record; type ParamType = string | ParameterType | ArrayParameterType; export enum PlaceHolder { @@ -84,7 +85,7 @@ export class QueryBuilder { /** * Executes an SQL query (SELECT) and returns a Result. */ - public executeQuery(): Promise> { + public executeQuery(): Promise> { return this.connection.executeQuery(this.getSQL(), this.params, this.types); } @@ -95,7 +96,7 @@ export class QueryBuilder { return this.connection.executeStatement(this.getSQL(), this.params, this.types); } - public async fetchAssociative(): Promise { + public async fetchAssociative(): Promise { return (await this.executeQuery()).fetchAssociative(); } @@ -111,7 +112,7 @@ export class QueryBuilder { return (await this.executeQuery()).fetchAllNumeric(); } - public async fetchAllAssociative(): Promise { + public async fetchAllAssociative(): Promise { return (await this.executeQuery()).fetchAllAssociative(); } @@ -119,7 +120,7 @@ export class QueryBuilder { return (await this.executeQuery()).fetchAllKeyValue(); } - public async fetchAllAssociativeIndexed(): Promise< + public async fetchAllAssociativeIndexed(): Promise< Record > { return (await this.executeQuery()).fetchAllAssociativeIndexed(); @@ -388,7 +389,7 @@ export class QueryBuilder { */ public insertWith( table: string, - data: DataMap, + data: AssociativeRow, placeHolder: PlaceHolder = PlaceHolder.POSITIONAL, ): this { if (!data || Object.keys(data).length === 0) { @@ -418,7 +419,7 @@ export class QueryBuilder { */ public updateWith( table: string, - data: DataMap, + data: AssociativeRow, placeHolder: PlaceHolder = PlaceHolder.POSITIONAL, ): this { if (!data || Object.keys(data).length === 0) { @@ -988,7 +989,7 @@ export class QueryBuilder { return this.types as ParamType[]; } - private ensureNamedParams(): DataMap { + private ensureNamedParams(): AssociativeRow { if (Array.isArray(this.params)) { this.params = {}; } diff --git a/src/result.ts b/src/result.ts index 150f13c..a3e66e6 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,73 +1,34 @@ -import type { DriverQueryResult } from "./driver"; -import { NoKeyValueException } from "./exception/index"; +import type { Result as DriverResult } from "./driver/result"; +import { NoKeyValue } from "./exception/no-key-value"; type AssociativeRow = Record; type NumericRow = unknown[]; +type DriverResultWithColumnName = DriverResult & { + getColumnName?: (index: number) => string; +}; + export class Result { - private rows: TRow[]; - private cursor = 0; - private readonly explicitColumns: string[]; - private readonly explicitRowCount?: number; - - constructor(result: DriverQueryResult) { - this.rows = [...result.rows] as TRow[]; - this.explicitColumns = result.columns ?? []; - this.explicitRowCount = result.rowCount; - } + constructor(private readonly result: DriverResult) {} public fetchNumeric(): T | false { - const row = this.fetchAssociative(); - if (row === false) { - return false; - } - - const columns = this.getColumnsFromRow(row); - return columns.map((column) => row[column]) as T; + return this.result.fetchNumeric() as T | false; } public fetchAssociative(): T | false { - const row = this.rows[this.cursor]; - if (row === undefined) { - return false; - } - - this.cursor += 1; - return { ...row } as unknown as T; + return this.result.fetchAssociative(); } public fetchOne(): T | false { - const row = this.fetchNumeric(); - if (row === false) { - return false; - } - - const value = row[0]; - return value === undefined ? false : (value as T); + return this.result.fetchOne(); } public fetchAllNumeric(): T[] { - const rows: T[] = []; - let row = this.fetchNumeric(); - - while (row !== false) { - rows.push(row); - row = this.fetchNumeric(); - } - - return rows; + return this.result.fetchAllNumeric() as T[]; } public fetchAllAssociative(): T[] { - const rows: T[] = []; - let row = this.fetchAssociative(); - - while (row !== false) { - rows.push(row); - row = this.fetchAssociative(); - } - - return rows; + return this.result.fetchAllAssociative(); } public fetchAllKeyValue(): Record { @@ -96,12 +57,7 @@ export class Result { const indexed: Record = {}; for (const row of rows) { - const columns = this.getColumnsFromRow(row); - const keyColumn = columns[0]; - if (keyColumn === undefined) { - continue; - } - + const keyColumn = this.getColumnName(0); const key = row[keyColumn]; const clone = { ...row }; delete clone[keyColumn]; @@ -112,64 +68,36 @@ export class Result { } public fetchFirstColumn(): T[] { - const values: T[] = []; - let value = this.fetchOne(); - - while (value !== false) { - values.push(value); - value = this.fetchOne(); - } - - return values; + return this.result.fetchFirstColumn(); } - public rowCount(): number { - return this.explicitRowCount ?? this.rows.length; + public rowCount(): number | string { + return this.result.rowCount(); } public columnCount(): number { - const row = this.rows[0]; - if (row === undefined) { - return this.explicitColumns.length; - } - - return this.getColumnsFromRow(row).length; + return this.result.columnCount(); } public getColumnName(index: number): string { - const columns = this.getColumns(); - const column = columns[index]; + const withColumnName = this.result as DriverResultWithColumnName; - if (column === undefined) { - throw new RangeError(`Column index ${index} is out of bounds.`); + if (typeof withColumnName.getColumnName === "function") { + return withColumnName.getColumnName(index); } - return column; + throw new Error("The driver result does not support accessing the column name."); } public free(): void { - this.rows = []; - this.cursor = 0; - } - - private getColumnsFromRow(row: AssociativeRow): string[] { - return this.explicitColumns.length > 0 ? this.explicitColumns : Object.keys(row); - } - - private getColumns(): string[] { - const row = this.rows[0]; - if (row !== undefined) { - return this.getColumnsFromRow(row); - } - - return this.explicitColumns; + this.result.free(); } private ensureHasKeyValue(): void { const columnCount = this.columnCount(); if (columnCount < 2) { - throw new NoKeyValueException(columnCount); + throw NoKeyValue.fromColumnCount(columnCount); } } } diff --git a/src/schema/abstract-asset.ts b/src/schema/abstract-asset.ts index cd50409..62df83f 100644 --- a/src/schema/abstract-asset.ts +++ b/src/schema/abstract-asset.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import type { AbstractPlatform } from "../platforms/abstract-platform"; /** - * Doctrine-inspired base class for schema assets (table, column, index, sequence...). + * Datazen base class for schema assets (table, column, index, sequence...). */ export abstract class AbstractAsset { protected _name = ""; diff --git a/src/schema/collections/index.ts b/src/schema/collections/index.ts deleted file mode 100644 index 30f438f..0000000 --- a/src/schema/collections/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type { Exception as CollectionsException } from "./exception"; -export { ObjectAlreadyExists } from "./exception/object-already-exists"; -export { ObjectDoesNotExist } from "./exception/object-does-not-exist"; -export { ObjectSet } from "./object-set"; -export { OptionallyUnqualifiedNamedObjectSet } from "./optionally-unqualified-named-object-set"; -export { UnqualifiedNamedObjectSet } from "./unqualified-named-object-set"; diff --git a/src/schema/column-editor.ts b/src/schema/column-editor.ts index 1aba94e..310a42b 100644 --- a/src/schema/column-editor.ts +++ b/src/schema/column-editor.ts @@ -1,6 +1,6 @@ import { Type } from "../types/type"; import { Column } from "./column"; -import { InvalidColumnDefinition } from "./exception/index"; +import { InvalidColumnDefinition } from "./exception/invalid-column-definition"; export class ColumnEditor { private name: string | null = null; diff --git a/src/schema/column.ts b/src/schema/column.ts index e1786b4..0e8982e 100644 --- a/src/schema/column.ts +++ b/src/schema/column.ts @@ -1,7 +1,8 @@ -import { Type, registerBuiltInTypes } from "../types/index"; +import { registerBuiltInTypes } from "../types/register-built-in-types"; +import { Type } from "../types/type"; import { AbstractAsset } from "./abstract-asset"; import { ColumnEditor } from "./column-editor"; -import { UnknownColumnOption } from "./exception/index"; +import { UnknownColumnOption } from "./exception/unknown-column-option"; export type ColumnOptions = Record; diff --git a/src/schema/default-expression/index.ts b/src/schema/default-expression/index.ts deleted file mode 100644 index 9acad71..0000000 --- a/src/schema/default-expression/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { CurrentDate } from "./current-date"; -export { CurrentTime } from "./current-time"; -export { CurrentTimestamp } from "./current-timestamp"; diff --git a/src/schema/exception/index.ts b/src/schema/exception/index.ts deleted file mode 100644 index f9f4283..0000000 --- a/src/schema/exception/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -export { ColumnAlreadyExists } from "./column-already-exists"; -export { ColumnDoesNotExist } from "./column-does-not-exist"; -export { ForeignKeyDoesNotExist } from "./foreign-key-does-not-exist"; -export { IncomparableNames } from "./incomparable-names"; -export { IndexAlreadyExists } from "./index-already-exists"; -export { IndexDoesNotExist } from "./index-does-not-exist"; -export { IndexNameInvalid } from "./index-name-invalid"; -export { InvalidColumnDefinition } from "./invalid-column-definition"; -export { InvalidForeignKeyConstraintDefinition } from "./invalid-foreign-key-constraint-definition"; -export { InvalidIdentifier } from "./invalid-identifier"; -export { InvalidIndexDefinition } from "./invalid-index-definition"; -export { InvalidName } from "./invalid-name"; -export { InvalidPrimaryKeyConstraintDefinition } from "./invalid-primary-key-constraint-definition"; -export { InvalidSequenceDefinition } from "./invalid-sequence-definition"; -export { InvalidState } from "./invalid-state"; -export { InvalidTableDefinition } from "./invalid-table-definition"; -export { InvalidTableModification } from "./invalid-table-modification"; -export { InvalidTableName } from "./invalid-table-name"; -export { InvalidUniqueConstraintDefinition } from "./invalid-unique-constraint-definition"; -export { InvalidViewDefinition } from "./invalid-view-definition"; -export { NamespaceAlreadyExists } from "./namespace-already-exists"; -export { NotImplemented } from "./not-implemented"; -export { PrimaryKeyAlreadyExists } from "./primary-key-already-exists"; -export { SequenceAlreadyExists } from "./sequence-already-exists"; -export { SequenceDoesNotExist } from "./sequence-does-not-exist"; -export { TableAlreadyExists } from "./table-already-exists"; -export { TableDoesNotExist } from "./table-does-not-exist"; -export { UniqueConstraintDoesNotExist } from "./unique-constraint-does-not-exist"; -export { UnknownColumnOption } from "./unknown-column-option"; -export { UnsupportedName } from "./unsupported-name"; -export { UnsupportedSchema } from "./unsupported-schema"; diff --git a/src/schema/foreign-key-constraint-editor.ts b/src/schema/foreign-key-constraint-editor.ts index 721830c..998ccd9 100644 --- a/src/schema/foreign-key-constraint-editor.ts +++ b/src/schema/foreign-key-constraint-editor.ts @@ -1,4 +1,4 @@ -import { InvalidForeignKeyConstraintDefinition } from "./exception/index"; +import { InvalidForeignKeyConstraintDefinition } from "./exception/invalid-foreign-key-constraint-definition"; import { ForeignKeyConstraint } from "./foreign-key-constraint"; import { Deferrability } from "./foreign-key-constraint/deferrability"; import { MatchType } from "./foreign-key-constraint/match-type"; diff --git a/src/schema/foreign-key-constraint/index.ts b/src/schema/foreign-key-constraint/index.ts deleted file mode 100644 index 3ea38d1..0000000 --- a/src/schema/foreign-key-constraint/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { Deferrability, deferrabilityToSQL } from "./deferrability"; -export { MatchType, matchTypeToSQL } from "./match-type"; -export { ReferentialAction, referentialActionToSQL } from "./referential-action"; diff --git a/src/schema/index-editor.ts b/src/schema/index-editor.ts index 1d0a1b7..7351d17 100644 --- a/src/schema/index-editor.ts +++ b/src/schema/index-editor.ts @@ -1,4 +1,4 @@ -import { InvalidIndexDefinition } from "./exception/index"; +import { InvalidIndexDefinition } from "./exception/invalid-index-definition"; import { Index } from "./index"; import { IndexType } from "./index/index-type"; import { IndexedColumn } from "./index/indexed-column"; diff --git a/src/schema/index/index.ts b/src/schema/index/index.ts deleted file mode 100644 index 477bea1..0000000 --- a/src/schema/index/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { IndexType } from "./index-type"; -export { IndexedColumn } from "./indexed-column"; diff --git a/src/schema/index/indexed-column.ts b/src/schema/index/indexed-column.ts index 75e31b7..04865a7 100644 --- a/src/schema/index/indexed-column.ts +++ b/src/schema/index/indexed-column.ts @@ -1,4 +1,4 @@ -import { InvalidIndexDefinition } from "../exception"; +import { InvalidIndexDefinition } from "../exception/invalid-index-definition"; import { UnqualifiedName } from "../name/unqualified-name"; export class IndexedColumn { diff --git a/src/schema/introspection/index.ts b/src/schema/introspection/index.ts deleted file mode 100644 index 4cabaaa..0000000 --- a/src/schema/introspection/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { IntrospectingSchemaProvider } from "./introspecting-schema-provider"; -export * as MetadataProcessor from "./metadata-processor/index"; diff --git a/src/schema/introspection/metadata-processor/index.ts b/src/schema/introspection/metadata-processor/index.ts deleted file mode 100644 index ebb50d8..0000000 --- a/src/schema/introspection/metadata-processor/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { ForeignKeyConstraintColumnMetadataProcessor } from "./foreign-key-constraint-column-metadata-processor"; -export { IndexColumnMetadataProcessor } from "./index-column-metadata-processor"; -export { PrimaryKeyConstraintColumnMetadataProcessor } from "./primary-key-constraint-column-metadata-processor"; -export { SequenceMetadataProcessor } from "./sequence-metadata-processor"; -export { ViewMetadataProcessor } from "./view-metadata-processor"; diff --git a/src/schema/metadata/index.ts b/src/schema/metadata/index.ts deleted file mode 100644 index 951b27d..0000000 --- a/src/schema/metadata/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { DatabaseMetadataRow } from "./database-metadata-row"; -export { ForeignKeyConstraintColumnMetadataRow } from "./foreign-key-constraint-column-metadata-row"; -export { IndexColumnMetadataRow } from "./index-column-metadata-row"; -export { MetadataProvider } from "./metadata-provider"; -export { PrimaryKeyConstraintColumnRow } from "./primary-key-constraint-column-row"; -export { SchemaMetadataRow } from "./schema-metadata-row"; -export { SequenceMetadataRow } from "./sequence-metadata-row"; -export { TableColumnMetadataRow } from "./table-column-metadata-row"; -export { TableMetadataRow } from "./table-metadata-row"; -export { ViewMetadataRow } from "./view-metadata-row"; diff --git a/src/schema/module.ts b/src/schema/module.ts deleted file mode 100644 index 8835782..0000000 --- a/src/schema/module.ts +++ /dev/null @@ -1,51 +0,0 @@ -export { AbstractAsset } from "./abstract-asset"; -export { AbstractNamedObject } from "./abstract-named-object"; -export { AbstractOptionallyNamedObject } from "./abstract-optionally-named-object"; -export { AbstractSchemaManager } from "./abstract-schema-manager"; -export * as Collections from "./collections/index"; -export { Column } from "./column"; -export { ColumnDiff } from "./column-diff"; -export { ColumnEditor } from "./column-editor"; -export { Comparator } from "./comparator"; -export { ComparatorConfig } from "./comparator-config"; -export { DB2SchemaManager } from "./db2-schema-manager"; -export type { DefaultExpression } from "./default-expression"; -export * as DefaultExpressions from "./default-expression/index"; -export { DefaultSchemaManagerFactory } from "./default-schema-manager-factory"; -export * as Exception from "./exception/index"; -export { ForeignKeyConstraint } from "./foreign-key-constraint"; -export * as ForeignKey from "./foreign-key-constraint/index"; -export { ForeignKeyConstraintEditor } from "./foreign-key-constraint-editor"; -export { Identifier } from "./identifier"; -export { Index } from "./index"; -export * as Indexing from "./index/index"; -export { IndexEditor } from "./index-editor"; -export * as Introspection from "./introspection/index"; -export * as Metadata from "./metadata/index"; -export { MySQLSchemaManager } from "./mysql-schema-manager"; -export type { Name } from "./name"; -export * as Names from "./name/index"; -export type { NamedObject } from "./named-object"; -export type { OptionallyNamedObject } from "./optionally-named-object"; -export { OracleSchemaManager } from "./oracle-schema-manager"; -export { PostgreSQLSchemaManager } from "./postgre-sql-schema-manager"; -export { PrimaryKeyConstraint } from "./primary-key-constraint"; -export { PrimaryKeyConstraintEditor } from "./primary-key-constraint-editor"; -export { Schema } from "./schema"; -export { SchemaConfig } from "./schema-config"; -export { SchemaDiff } from "./schema-diff"; -export type { SchemaException } from "./schema-exception"; -export type { SchemaManagerFactory } from "./schema-manager-factory"; -export type { SchemaProvider } from "./schema-provider"; -export { Sequence } from "./sequence"; -export { SequenceEditor } from "./sequence-editor"; -export { SQLServerSchemaManager } from "./sql-server-schema-manager"; -export { SQLiteSchemaManager } from "./sqlite-schema-manager"; -export { Table } from "./table"; -export { TableConfiguration } from "./table-configuration"; -export { TableDiff } from "./table-diff"; -export { TableEditor } from "./table-editor"; -export { UniqueConstraint } from "./unique-constraint"; -export { UniqueConstraintEditor } from "./unique-constraint-editor"; -export { View } from "./view"; -export { ViewEditor } from "./view-editor"; diff --git a/src/schema/name/index.ts b/src/schema/name/index.ts deleted file mode 100644 index cf73252..0000000 --- a/src/schema/name/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { GenericName } from "./generic-name"; -export { Identifier } from "./identifier"; -export { OptionallyQualifiedName } from "./optionally-qualified-name"; -export type { Parser } from "./parser"; -export * as ParserNamespace from "./parser/index"; -export { Parsers } from "./parsers"; -export { UnqualifiedName } from "./unqualified-name"; -export { UnquotedIdentifierFolding, foldUnquotedIdentifier } from "./unquoted-identifier-folding"; diff --git a/src/schema/name/parser/exception/index.ts b/src/schema/name/parser/exception/index.ts deleted file mode 100644 index 2a52db9..0000000 --- a/src/schema/name/parser/exception/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { Exception as ParserException } from "../exception"; -export { ExpectedDot } from "./expected-dot"; -export { ExpectedNextIdentifier } from "./expected-next-identifier"; -export { InvalidName } from "./invalid-name"; -export { UnableToParseIdentifier } from "./unable-to-parse-identifier"; diff --git a/src/schema/name/parser/index.ts b/src/schema/name/parser/index.ts deleted file mode 100644 index d9e3ac8..0000000 --- a/src/schema/name/parser/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { Parser } from "../parser"; -export * as Exception from "./exception/index"; -export { GenericNameParser } from "./generic-name-parser"; -export { OptionallyQualifiedNameParser } from "./optionally-qualified-name-parser"; -export { UnqualifiedNameParser } from "./unqualified-name-parser"; diff --git a/src/schema/primary-key-constraint.ts b/src/schema/primary-key-constraint.ts index a62046e..64b09c0 100644 --- a/src/schema/primary-key-constraint.ts +++ b/src/schema/primary-key-constraint.ts @@ -1,4 +1,4 @@ -import { InvalidPrimaryKeyConstraintDefinition } from "./exception/index"; +import { InvalidPrimaryKeyConstraintDefinition } from "./exception/invalid-primary-key-constraint-definition"; import { PrimaryKeyConstraintEditor } from "./primary-key-constraint-editor"; export class PrimaryKeyConstraint { diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 3abef04..fa331df 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -1,11 +1,9 @@ import { AbstractAsset } from "./abstract-asset"; -import { - NamespaceAlreadyExists, - SequenceAlreadyExists, - SequenceDoesNotExist, - TableAlreadyExists, - TableDoesNotExist, -} from "./exception/index"; +import { NamespaceAlreadyExists } from "./exception/namespace-already-exists"; +import { SequenceAlreadyExists } from "./exception/sequence-already-exists"; +import { SequenceDoesNotExist } from "./exception/sequence-does-not-exist"; +import { TableAlreadyExists } from "./exception/table-already-exists"; +import { TableDoesNotExist } from "./exception/table-does-not-exist"; import { SchemaConfig } from "./schema-config"; import { Sequence } from "./sequence"; import { Table } from "./table"; diff --git a/src/schema/sequence-editor.ts b/src/schema/sequence-editor.ts index 6e68bf0..3fcf1c6 100644 --- a/src/schema/sequence-editor.ts +++ b/src/schema/sequence-editor.ts @@ -1,4 +1,4 @@ -import { InvalidSequenceDefinition } from "./exception/index"; +import { InvalidSequenceDefinition } from "./exception/invalid-sequence-definition"; import { Sequence } from "./sequence"; export class SequenceEditor { diff --git a/src/schema/table-editor.ts b/src/schema/table-editor.ts index e49b9cb..c7bf375 100644 --- a/src/schema/table-editor.ts +++ b/src/schema/table-editor.ts @@ -1,5 +1,5 @@ import { Column } from "./column"; -import { InvalidTableDefinition } from "./exception/index"; +import { InvalidTableDefinition } from "./exception/invalid-table-definition"; import { ForeignKeyConstraint } from "./foreign-key-constraint"; import { Index } from "./index"; import { PrimaryKeyConstraint } from "./primary-key-constraint"; diff --git a/src/schema/table.ts b/src/schema/table.ts index 4b46abe..ce67cee 100644 --- a/src/schema/table.ts +++ b/src/schema/table.ts @@ -2,15 +2,13 @@ import { Type } from "../types/type"; import { AbstractAsset } from "./abstract-asset"; import type { ColumnOptions } from "./column"; import { Column } from "./column"; -import { - ColumnAlreadyExists, - ColumnDoesNotExist, - ForeignKeyDoesNotExist, - IndexAlreadyExists, - IndexDoesNotExist, - InvalidState, - PrimaryKeyAlreadyExists, -} from "./exception/index"; +import { ColumnAlreadyExists } from "./exception/column-already-exists"; +import { ColumnDoesNotExist } from "./exception/column-does-not-exist"; +import { ForeignKeyDoesNotExist } from "./exception/foreign-key-does-not-exist"; +import { IndexAlreadyExists } from "./exception/index-already-exists"; +import { IndexDoesNotExist } from "./exception/index-does-not-exist"; +import { InvalidState } from "./exception/invalid-state"; +import { PrimaryKeyAlreadyExists } from "./exception/primary-key-already-exists"; import { ForeignKeyConstraint } from "./foreign-key-constraint"; import { Index } from "./index"; import { TableEditor } from "./table-editor"; diff --git a/src/schema/unique-constraint.ts b/src/schema/unique-constraint.ts index 5459a5a..ab2d645 100644 --- a/src/schema/unique-constraint.ts +++ b/src/schema/unique-constraint.ts @@ -1,5 +1,5 @@ import type { AbstractPlatform } from "../platforms/abstract-platform"; -import { InvalidUniqueConstraintDefinition } from "./exception/index"; +import { InvalidUniqueConstraintDefinition } from "./exception/invalid-unique-constraint-definition"; import { Identifier } from "./identifier"; import { UniqueConstraintEditor } from "./unique-constraint-editor"; diff --git a/src/schema/view-editor.ts b/src/schema/view-editor.ts index 97f28c9..7696c8c 100644 --- a/src/schema/view-editor.ts +++ b/src/schema/view-editor.ts @@ -1,4 +1,4 @@ -import { InvalidViewDefinition } from "./exception/index"; +import { InvalidViewDefinition } from "./exception/invalid-view-definition"; import { View } from "./view"; export class ViewEditor { diff --git a/src/server-version-provider.ts b/src/server-version-provider.ts index 08d79f8..2a03555 100644 --- a/src/server-version-provider.ts +++ b/src/server-version-provider.ts @@ -1,3 +1,6 @@ export interface ServerVersionProvider { + /** + * Returns the database server version + */ getServerVersion(): string | Promise; } diff --git a/src/sql/builder/default-select-sql-builder.ts b/src/sql/builder/default-select-sql-builder.ts index bd4ad22..2baff4d 100644 --- a/src/sql/builder/default-select-sql-builder.ts +++ b/src/sql/builder/default-select-sql-builder.ts @@ -1,6 +1,6 @@ import { AbstractPlatform } from "../../platforms/abstract-platform"; import { NotSupported } from "../../platforms/exception/not-supported"; -import { ConflictResolutionMode } from "../../query/for-update"; +import { ConflictResolutionMode } from "../../query/for-update/conflict-resolution-mode"; import { SelectQuery } from "../../query/select-query"; import { SelectSQLBuilder } from "./select-sql-builder"; diff --git a/src/sql/index.ts b/src/sql/index.ts deleted file mode 100644 index 7503776..0000000 --- a/src/sql/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { DefaultSelectSQLBuilder } from "./builder/default-select-sql-builder"; -export { DefaultUnionSQLBuilder } from "./builder/default-union-sql-builder"; -export type { SelectSQLBuilder } from "./builder/select-sql-builder"; -export type { UnionSQLBuilder } from "./builder/union-sql-builder"; -export { WithSQLBuilder } from "./builder/with-sql-builder"; -export type { SQLParser, Visitor, Visitor as SQLParserVisitor } from "./parser"; -export { Parser, ParserException, RegularExpressionException } from "./parser"; -export { Exception as SQLParserException } from "./parser/exception"; diff --git a/src/sql/parser.ts b/src/sql/parser.ts index 8a9ea08..915ce04 100644 --- a/src/sql/parser.ts +++ b/src/sql/parser.ts @@ -16,7 +16,7 @@ const OTHER = `[^${SPECIAL_CHARS}]+`; /** * SQL parser focused on identifying prepared statement parameters. - * Ported from Doctrine DBAL's SQL parser approach. + * Ported for Datazen using the DBAL-style SQL parser approach. */ export class Parser implements SQLParser { private readonly tokenExpression: RegExp; diff --git a/src/sql/parser/exception.ts b/src/sql/parser/exception.ts index f4f5457..6c500e0 100644 --- a/src/sql/parser/exception.ts +++ b/src/sql/parser/exception.ts @@ -1,3 +1,8 @@ -import { DbalException } from "../../exception/index"; +import { initializeException } from "../../exception/_util"; -export class Exception extends DbalException {} +export class Exception extends Error { + constructor(message: string) { + super(message); + initializeException(this, new.target); + } +} diff --git a/src/statement.ts b/src/statement.ts index b5bdfee..d5ab7f5 100644 --- a/src/statement.ts +++ b/src/statement.ts @@ -1,7 +1,6 @@ -import { MixedParameterStyleException } from "./exception/index"; import { ParameterType } from "./parameter-type"; +import type { QueryParameterType, QueryParameterTypes, QueryParameters } from "./query"; import type { Result } from "./result"; -import type { QueryParameterType, QueryParameterTypes, QueryParameters } from "./types"; export interface StatementExecutor { executeQuery(sql: string, params?: QueryParameters, types?: QueryParameterTypes): Promise; @@ -81,7 +80,10 @@ export class Statement { const hasPositional = this.positionalParams.length > 0; if (hasNamed && hasPositional) { - throw new MixedParameterStyleException(); + return [ + { ...Object.assign({}, this.positionalParams), ...this.namedParams }, + { ...Object.assign({}, this.positionalTypes), ...this.namedTypes }, + ]; } if (hasNamed) { diff --git a/src/static-server-version-provider.ts b/src/static-server-version-provider.ts deleted file mode 100644 index 7b91ca6..0000000 --- a/src/static-server-version-provider.ts +++ /dev/null @@ -1 +0,0 @@ -export { StaticServerVersionProvider } from "./connection/static-server-version-provider"; diff --git a/src/tools/console/command/.gitkeep b/src/tools/console/command/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/console/connection-provider/.gitkeep b/src/tools/console/connection-provider/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/dsn-parser.ts b/src/tools/dsn-parser.ts index a42dfa2..bb15346 100644 --- a/src/tools/dsn-parser.ts +++ b/src/tools/dsn-parser.ts @@ -1,5 +1,5 @@ import type { Driver } from "../driver"; -import { MalformedDsnException } from "../exception/index"; +import { MalformedDsnException } from "../exception/malformed-dsn-exception"; export type DsnSchemeMappingValue = string | (new () => Driver); export type DsnSchemeMapping = Record; diff --git a/src/tools/index.ts b/src/tools/index.ts deleted file mode 100644 index d91bc51..0000000 --- a/src/tools/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { DsnConnectionParams, DsnSchemeMapping, DsnSchemeMappingValue } from "./dsn-parser"; -export { DsnParser } from "./dsn-parser"; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 39315db..0000000 --- a/src/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ArrayParameterType } from "./array-parameter-type"; -import { ParameterType } from "./parameter-type"; -import type { Type } from "./types/type"; - -export type QueryScalarParameterType = ParameterType | string | Type; -export type QueryParameterType = QueryScalarParameterType | ArrayParameterType; -export type QueryParameters = unknown[] | Record; -export type QueryParameterTypes = QueryParameterType[] | Record; - -export interface CompiledQuery { - sql: string; - parameters: QueryParameters; - types: QueryScalarParameterType[] | Record; -} diff --git a/src/types/exception/types-exception.ts b/src/types/exception/types-exception.ts index 9915bca..575a2f2 100644 --- a/src/types/exception/types-exception.ts +++ b/src/types/exception/types-exception.ts @@ -1,3 +1,8 @@ -import { DbalException } from "../../exception/index"; +import { initializeException } from "../../exception/_util"; -export class TypesException extends DbalException {} +export class TypesException extends Error { + constructor(message: string) { + super(message); + initializeException(this, new.target); + } +} diff --git a/src/types/index.ts b/src/types/register-built-in-types.ts similarity index 59% rename from src/types/index.ts rename to src/types/register-built-in-types.ts index 9e261e7..46f800f 100644 --- a/src/types/index.ts +++ b/src/types/register-built-in-types.ts @@ -76,54 +76,3 @@ export function registerBuiltInTypes(): void { builtinsRegistered = true; } - -registerBuiltInTypes(); - -export { AsciiStringType } from "./ascii-string-type"; -export { BigIntType } from "./big-int-type"; -export { BinaryType } from "./binary-type"; -export { BlobType } from "./blob-type"; -export { BooleanType } from "./boolean-type"; -export { ConversionException } from "./conversion-exception"; -export { DateImmutableType } from "./date-immutable-type"; -export { DateIntervalType } from "./date-interval-type"; -export { DateTimeImmutableType } from "./date-time-immutable-type"; -export { DateTimeType } from "./date-time-type"; -export { DateTimeTzImmutableType } from "./date-time-tz-immutable-type"; -export { DateTimeTzType } from "./date-time-tz-type"; -export { DateType } from "./date-type"; -export { DecimalType } from "./decimal-type"; -export { EnumType } from "./enum-type"; -export { - InvalidFormat, - InvalidType, - SerializationFailed, - TypeAlreadyRegistered, - TypeArgumentCountException, - TypeNotFound, - TypeNotRegistered, - TypesAlreadyExists, - TypesException, - UnknownColumnType, - ValueNotConvertible, -} from "./exception/index"; -export { FloatType } from "./float-type"; -export { GuidType } from "./guid-type"; -export { IntegerType } from "./integer-type"; -export { JsonObjectType } from "./json-object-type"; -export { JsonType } from "./json-type"; -export { JsonbObjectType } from "./jsonb-object-type"; -export { JsonbType } from "./jsonb-type"; -export { NumberType } from "./number-type"; -export { SimpleArrayType } from "./simple-array-type"; -export { SmallFloatType } from "./small-float-type"; -export { SmallIntType } from "./small-int-type"; -export { StringType } from "./string-type"; -export { TextType } from "./text-type"; -export { TimeImmutableType } from "./time-immutable-type"; -export { TimeType } from "./time-type"; -export { Type } from "./type"; -export { TypeRegistry } from "./type-registry"; -export { Types } from "./types"; -export { VarDateTimeImmutableType } from "./var-date-time-immutable-type"; -export { VarDateTimeType } from "./var-date-time-type"; diff --git a/tsconfig.json b/tsconfig.json index 3d40baa..eacbdd7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["./src"], - "exclude": ["__tests__"], + "exclude": ["__tests__", "./src/__tests__", "./src/test.ts"], "compilerOptions": { "module": "esnext", "target": "esnext", From 0ee29f045aa26c47852f231661a39d01cd6e9273 Mon Sep 17 00:00:00 2001 From: bernard-ng Date: Tue, 24 Feb 2026 08:06:08 +0200 Subject: [PATCH 06/24] feat: more optimization and api alignment --- CHANGELOG.md | 5 + bun.lock | 243 +++++++++++- .../connection-data-manipulation.test.ts | 6 +- ...database-platform-version-provider.test.ts | 2 +- .../connection-exception-conversion.test.ts | 2 +- .../connection-parameter-compilation.test.ts | 2 +- .../connection/connection-transaction.test.ts | 2 +- .../connection-type-conversion.test.ts | 2 +- .../connection/connection-typed-fetch.test.ts | 2 +- ...-oracle-driver-easy-connect-string.test.ts | 29 +- .../driver/abstract-oracle-driver.test.ts | 29 ++ .../driver/driver-result-classes.test.ts | 65 ++++ src/__tests__/logging/middleware.test.ts | 2 +- src/__tests__/portability/middleware.test.ts | 2 +- src/__tests__/query/query-builder.test.ts | 4 +- src/__tests__/result/result.test.ts | 69 +++- src/__tests__/schema/schema-manager.test.ts | 2 +- src/__tests__/statement/statement.test.ts | 287 +++++++++----- src/__tests__/tools/porting.test.ts | 159 ++++++++ src/__tests__/types/types.test.ts | 12 +- src/_internal.ts | 351 ++++++++++++++++++ src/connection.ts | 64 ++-- .../primary-read-replica-connection.ts | 18 +- src/driver.ts | 21 -- ...arameter-binding-style.ts => _internal.ts} | 0 src/driver/abstract-oracle-driver.ts | 22 ++ .../easy-connect-string.ts | 97 +---- .../middleware/enable-foreign-keys.ts | 3 +- src/driver/exception.ts | 11 - src/driver/internal-result-types.ts | 10 - src/driver/mssql/connection.ts | 4 +- src/driver/mssql/driver.ts | 4 +- src/driver/mssql/exception-converter.ts | 3 - src/driver/mssql/result.ts | 95 +++++ src/driver/mysql2/connection.ts | 6 +- src/driver/mysql2/driver.ts | 4 +- src/driver/mysql2/exception-converter.ts | 3 - src/driver/mysql2/result.ts | 95 +++++ src/driver/pg/connection.ts | 8 +- src/driver/pg/driver.ts | 4 +- src/driver/pg/exception-converter.ts | 3 - src/driver/pg/result.ts | 95 +++++ src/driver/result.ts | 62 ---- src/driver/sqlite3/connection.ts | 12 +- src/driver/sqlite3/driver.ts | 4 +- src/driver/sqlite3/exception-converter.ts | 3 - src/driver/sqlite3/result.ts | 95 +++++ src/driver/statement.ts | 23 -- src/exception/{_util.ts => _internal.ts} | 2 +- src/exception/database-required.ts | 2 +- src/exception/driver-exception.ts | 2 +- src/exception/invalid-argument-exception.ts | 2 +- src/exception/invalid-column-declaration.ts | 2 +- src/exception/invalid-column-index.ts | 2 +- src/exception/invalid-column-type.ts | 2 +- src/exception/invalid-parameter-exception.ts | 2 +- src/exception/malformed-dsn-exception.ts | 2 +- .../missing-named-parameter-exception.ts | 2 +- .../missing-positional-parameter-exception.ts | 2 +- src/exception/no-key-value.ts | 2 +- src/exception/parse-error.ts | 2 +- src/logging/driver.ts | 3 +- src/portability/connection.ts | 2 +- src/portability/driver.ts | 3 +- src/portability/middleware.ts | 3 +- src/query.ts | 6 - src/result.ts | 42 ++- .../exception/{_util.ts => _internal.ts} | 0 src/schema/exception/incomparable-names.ts | 2 +- .../exception/invalid-column-definition.ts | 2 +- ...valid-foreign-key-constraint-definition.ts | 2 +- .../exception/invalid-index-definition.ts | 2 +- .../exception/invalid-table-definition.ts | 2 +- .../exception/invalid-table-modification.ts | 2 +- .../invalid-unique-constraint-definition.ts | 2 +- .../exception/invalid-view-definition.ts | 2 +- src/sql/parser/exception.ts | 2 +- src/statement.ts | 184 +++++---- src/tools/console/command/.gitkeep | 0 .../console/connection-provider/.gitkeep | 0 src/types/exception/index.ts | 11 - src/types/exception/types-exception.ts | 2 +- src/types/json-type-convert.ts | 3 +- src/types/type-registry.ts | 12 +- 84 files changed, 1798 insertions(+), 563 deletions(-) create mode 100644 src/__tests__/driver/abstract-oracle-driver.test.ts create mode 100644 src/__tests__/driver/driver-result-classes.test.ts create mode 100644 src/__tests__/tools/porting.test.ts create mode 100644 src/_internal.ts rename src/driver/{internal-parameter-binding-style.ts => _internal.ts} (100%) create mode 100644 src/driver/abstract-oracle-driver.ts delete mode 100644 src/driver/internal-result-types.ts delete mode 100644 src/driver/mssql/exception-converter.ts create mode 100644 src/driver/mssql/result.ts delete mode 100644 src/driver/mysql2/exception-converter.ts create mode 100644 src/driver/mysql2/result.ts delete mode 100644 src/driver/pg/exception-converter.ts create mode 100644 src/driver/pg/result.ts delete mode 100644 src/driver/sqlite3/exception-converter.ts create mode 100644 src/driver/sqlite3/result.ts rename src/exception/{_util.ts => _internal.ts} (90%) rename src/schema/exception/{_util.ts => _internal.ts} (100%) delete mode 100644 src/tools/console/command/.gitkeep delete mode 100644 src/tools/console/connection-provider/.gitkeep delete mode 100644 src/types/exception/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f647dab..7566415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # @devscast/datazen # Unreleased +- Driver: implemented per-driver in-memory result classes (`src/driver/mysql2/result.ts`, `src/driver/pg/result.ts`, `src/driver/sqlite3/result.ts`, `src/driver/mssql/result.ts`) and switched those driver connections to use them instead of the shared `ArrayResult`, using `FetchUtils` for shared fetch helpers and adding parity tests. +- Driver: added missing Doctrine-style `AbstractOracleDriver` (`src/driver/abstract-oracle-driver.ts`) wired to `OraclePlatform` and the OCI exception converter, with parity coverage in `src/__tests__/driver/abstract-oracle-driver.test.ts`. +- Result: restored Doctrine-style `Result` constructor parity to store the DBAL `Connection`, updated `Connection`/`Statement` call sites to pass it, and wrapped driver-result method exceptions through `connection.convertException(...)` (with result tests covering conversion). +- Porting: added `src/porting.ts` with pragmatic Node helpers for PHP-style ported code (`array_change_key_case`, `is_int`, `is_string`, `is_boolean`/`is_bool`, `key`, `array_key_exists`, `array_fill`, `method_exists`, `array_column`, `assert`, `version_compare`), with `version_compare()` now backed by `semver` plus a small PHP-suffix normalization shim (`dev`/`alpha`/`beta`/`rc`/`pl`), and targeted tests in `src/__tests__/tools/porting.test.ts`. +- Statement: restored Doctrine-style `Statement` construction/parity (`Connection` + driver `Statement` + SQL), removed the non-Doctrine `StatementExecutor` wrapper path, and fixed `Statement.bindValue()` to perform Datazen `Type`/type-name conversion and binding-type resolution before delegating to the driver statement; added statement tests covering type-name/type-instance binding conversion and execution error query-context capture. - Exception: removed the port-specific `DbalException` base class and switched DBAL exception pass-through detection to an internal marker helper (`src/exception/_util.ts`), updated adapter runtime guard errors to plain `Error`, and aligned Doctrine exception hierarchy/tests (including `SyntaxErrorException`/`TransactionRolledBack` server-exception inheritance and `sql-syntax` test usage -> `SyntaxErrorException`). - Exception: made `src/exception/driver-exception.ts` support Doctrine-style wrapping (`new DriverException(driverException, query?)`) with query-aware prefixed messages plus `getSQLState()`/`getQuery()`, while preserving the current converter-facing normalized-details constructor during the rewrite. - Tests: cleaned up remaining failing `logging`, `portability`, `query`, `result`, `tools`, `schema`, `types`, and `package` suites to use direct imports (no removed barrels), Doctrine-shaped driver test doubles (`prepare/query/exec` + `ArrayResult`), and internal binding-style test helpers instead of public `ParameterBindingStyle`; full test suite is green again. diff --git a/bun.lock b/bun.lock index 1876fd3..fd6b20a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "datazend-ts", @@ -27,6 +28,8 @@ "peerDependencies": { "mssql": "^12.2.0", "mysql2": "^3.17.2", + "pg": "^8.11.5", + "sqlite3": "^5.1.7", "typescript": "^5.9.3", }, }, @@ -224,6 +227,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.0", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="], + "@gar/promisify": ["@gar/promisify@1.1.3", "", {}, "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -254,6 +259,10 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@npmcli/fs": ["@npmcli/fs@1.1.1", "", { "dependencies": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" } }, "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ=="], + + "@npmcli/move-file": ["@npmcli/move-file@1.1.2", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], @@ -308,6 +317,8 @@ "@tediousjs/connection-string": ["@tediousjs/connection-string@0.6.0", "", {}, "sha512-GxlsW354Vi6QqbUgdPyQVcQjI7cZBdGV5vOYVYuCVDTylx2wl3WHR2HlhcxxHTrMigbelpXsdcZso+66uxPfow=="], + "@tootallnate/once": ["@tootallnate/once@1.1.2", "", {}, "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -362,13 +373,19 @@ "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + + "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -382,6 +399,10 @@ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="], + + "are-we-there-yet": ["are-we-there-yet@3.0.1", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], @@ -400,6 +421,8 @@ "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="], "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], @@ -418,6 +441,8 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "cacache": ["cacache@15.3.0", "", { "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "glob": "^7.1.4", "infer-owner": "^1.0.4", "lru-cache": "^6.0.0", "minipass": "^3.1.1", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.2", "mkdirp": "^1.0.3", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^8.0.1", "tar": "^6.0.2", "unique-filename": "^1.1.1" } }, "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ=="], + "cachedir": ["cachedir@2.3.0", "", {}, "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -430,8 +455,12 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -446,6 +475,8 @@ "color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "commitizen": ["commitizen@4.3.1", "", { "dependencies": { "cachedir": "2.3.0", "cz-conventional-changelog": "3.3.0", "dedent": "0.7.0", "detect-indent": "6.1.0", "find-node-modules": "^2.1.2", "find-root": "1.1.0", "fs-extra": "9.1.0", "glob": "7.2.3", "inquirer": "8.2.5", "is-utf8": "^0.2.1", "lodash": "4.17.21", "minimist": "1.2.7", "strip-bom": "4.0.0", "strip-json-comments": "3.1.1" }, "bin": { "cz": "bin/git-cz", "git-cz": "bin/git-cz", "commitizen": "bin/commitizen" } }, "sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw=="], @@ -458,6 +489,8 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], + "conventional-changelog-angular": ["conventional-changelog-angular@8.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w=="], "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA=="], @@ -478,8 +511,12 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "dedent": ["dedent@0.7.0", "", {}, "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -490,12 +527,16 @@ "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "detect-file": ["detect-file@1.0.0", "", {}, "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], @@ -504,10 +545,16 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -542,6 +589,8 @@ "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expand-tilde": ["expand-tilde@2.0.2", "", { "dependencies": { "homedir-polyfill": "^1.0.1" } }, "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], @@ -568,6 +617,8 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "find-node-modules": ["find-node-modules@2.1.3", "", { "dependencies": { "findup-sync": "^4.0.0", "merge": "^2.1.1" } }, "sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg=="], @@ -584,18 +635,26 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "gauge": ["gauge@4.0.4", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", "console-control-strings": "^1.1.0", "has-unicode": "^2.0.1", "signal-exit": "^3.0.7", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.5" } }, "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "git-raw-commits": ["git-raw-commits@4.0.0", "", { "dependencies": { "dargs": "^8.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.mjs" } }, "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -612,14 +671,20 @@ "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="], + "homedir-polyfill": ["homedir-polyfill@1.0.3", "", { "dependencies": { "parse-passwd": "^1.0.0" } }, "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA=="], - "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "http-proxy-agent": ["http-proxy-agent@4.0.1", "", { "dependencies": { "@tootallnate/once": "1", "agent-base": "6", "debug": "4" } }, "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg=="], + + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -634,14 +699,20 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "infer-owner": ["infer-owner@1.0.4", "", {}, "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "inquirer": ["inquirer@8.2.5", "", { "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "external-editor": "^3.0.3", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", "ora": "^5.4.1", "run-async": "^2.4.0", "rxjs": "^7.5.5", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", "wrap-ansi": "^7.0.0" } }, "sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], @@ -656,6 +727,8 @@ "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], + "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], @@ -750,10 +823,14 @@ "longest": ["longest@2.0.1", "", {}, "sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q=="], + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "make-fetch-happen": ["make-fetch-happen@9.1.0", "", { "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^4.0.1", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^6.0.0", "minipass": "^3.1.3", "minipass-collect": "^1.0.2", "minipass-fetch": "^1.3.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.2", "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" } }, "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg=="], + "meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], "merge": ["merge@2.1.1", "", {}, "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w=="], @@ -764,10 +841,30 @@ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="], "minimist": ["minimist@1.2.7", "", {}, "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g=="], + "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "minipass-collect": ["minipass-collect@1.0.2", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA=="], + + "minipass-fetch": ["minipass-fetch@1.4.1", "", { "dependencies": { "minipass": "^3.1.0", "minipass-sized": "^1.0.3", "minizlib": "^2.0.0" }, "optionalDependencies": { "encoding": "^0.1.12" } }, "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw=="], + + "minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], + + "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -786,10 +883,24 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-gyp": ["node-gyp@8.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^9.1.0", "nopt": "^5.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w=="], + + "nopt": ["nopt@5.0.0", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ=="], + + "npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], @@ -836,6 +947,22 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pg": ["pg@8.18.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], + + "pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.11.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w=="], + + "pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -850,18 +977,36 @@ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], + + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], @@ -878,8 +1023,12 @@ "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -898,6 +1047,8 @@ "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -906,8 +1057,18 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@6.2.1", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -920,6 +1081,10 @@ "sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="], + "sqlite3": ["sqlite3@5.1.7", "", { "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1", "tar": "^6.1.11" }, "optionalDependencies": { "node-gyp": "8.x" } }, "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog=="], + + "ssri": ["ssri@8.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], @@ -938,6 +1103,12 @@ "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="], "tedious": ["tedious@19.2.1", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.5", "@types/node": ">=18", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA=="], @@ -972,6 +1143,8 @@ "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], @@ -982,6 +1155,10 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unique-filename": ["unique-filename@1.1.1", "", { "dependencies": { "unique-slug": "^2.0.0" } }, "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ=="], + + "unique-slug": ["unique-slug@2.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1000,6 +1177,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1008,8 +1187,12 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1034,12 +1217,24 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@typespec/ts-http-runtime/http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "are-we-there-yet/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "cacache/p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + "chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "commitizen/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "external-editor/chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], @@ -1050,9 +1245,13 @@ "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "gauge/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "global-prefix/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "global-directory/ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], "global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], @@ -1064,22 +1263,50 @@ "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "prebuild-install/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "rc/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], "read-yaml-file/strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "tar-stream/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -1090,6 +1317,10 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@typespec/ts-http-runtime/http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "@typespec/ts-http-runtime/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "commitizen/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "commitizen/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -1114,6 +1345,8 @@ "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "tar-stream/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], diff --git a/src/__tests__/connection/connection-data-manipulation.test.ts b/src/__tests__/connection/connection-data-manipulation.test.ts index c69a476..7c1e04b 100644 --- a/src/__tests__/connection/connection-data-manipulation.test.ts +++ b/src/__tests__/connection/connection-data-manipulation.test.ts @@ -2,16 +2,16 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverException } from "../../exception/driver-exception"; import { ParameterType } from "../../parameter-type"; import { MySQLPlatform } from "../../platforms/mysql-platform"; -import type { CompiledQuery } from "./query"; +import { Query } from "../../query"; class NoopExceptionConverter implements ExceptionConverter { public convert(error: unknown, context: ExceptionConverterContext): DriverException { @@ -26,7 +26,7 @@ class NoopExceptionConverter implements ExceptionConverter { } class CaptureConnection implements DriverConnection { - public latestStatement: CompiledQuery | null = null; + public latestStatement: Query | null = null; public async prepare(sql: string) { const boundValues = new Map(); diff --git a/src/__tests__/connection/connection-database-platform-version-provider.test.ts b/src/__tests__/connection/connection-database-platform-version-provider.test.ts index bfa9e81..3eeb8ae 100644 --- a/src/__tests__/connection/connection-database-platform-version-provider.test.ts +++ b/src/__tests__/connection/connection-database-platform-version-provider.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; import { StaticServerVersionProvider } from "../../connection/static-server-version-provider"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverException } from "../../exception/driver-exception"; import { MySQLPlatform } from "../../platforms/mysql-platform"; import type { ServerVersionProvider } from "../../server-version-provider"; diff --git a/src/__tests__/connection/connection-exception-conversion.test.ts b/src/__tests__/connection/connection-exception-conversion.test.ts index e799902..9730aa8 100644 --- a/src/__tests__/connection/connection-exception-conversion.test.ts +++ b/src/__tests__/connection/connection-exception-conversion.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { ConnectionException } from "../../exception/connection-exception"; import { DriverException } from "../../exception/driver-exception"; import { InvalidParameterException } from "../../exception/invalid-parameter-exception"; diff --git a/src/__tests__/connection/connection-parameter-compilation.test.ts b/src/__tests__/connection/connection-parameter-compilation.test.ts index b84c521..dce5e57 100644 --- a/src/__tests__/connection/connection-parameter-compilation.test.ts +++ b/src/__tests__/connection/connection-parameter-compilation.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverException } from "../../exception/driver-exception"; import { ParameterType } from "../../parameter-type"; import { MySQLPlatform } from "../../platforms/mysql-platform"; diff --git a/src/__tests__/connection/connection-transaction.test.ts b/src/__tests__/connection/connection-transaction.test.ts index 893f866..5b864a2 100644 --- a/src/__tests__/connection/connection-transaction.test.ts +++ b/src/__tests__/connection/connection-transaction.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from "vitest"; import { Configuration } from "../../configuration"; import { Connection } from "../../connection"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { CommitFailedRollbackOnly } from "../../exception/commit-failed-rollback-only"; import { DriverException } from "../../exception/driver-exception"; import { NoActiveTransaction } from "../../exception/no-active-transaction"; diff --git a/src/__tests__/connection/connection-type-conversion.test.ts b/src/__tests__/connection/connection-type-conversion.test.ts index 731970c..b1a222f 100644 --- a/src/__tests__/connection/connection-type-conversion.test.ts +++ b/src/__tests__/connection/connection-type-conversion.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from "vitest"; import { ArrayParameterType } from "../../array-parameter-type"; import { Connection } from "../../connection"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverException } from "../../exception/driver-exception"; import { ParameterType } from "../../parameter-type"; import { MySQLPlatform } from "../../platforms/mysql-platform"; diff --git a/src/__tests__/connection/connection-typed-fetch.test.ts b/src/__tests__/connection/connection-typed-fetch.test.ts index 1028c52..62d4af8 100644 --- a/src/__tests__/connection/connection-typed-fetch.test.ts +++ b/src/__tests__/connection/connection-typed-fetch.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from "vitest"; import { Connection } from "../../connection"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverException } from "../../exception/driver-exception"; import { MySQLPlatform } from "../../platforms/mysql-platform"; diff --git a/src/__tests__/driver/abstract-oracle-driver-easy-connect-string.test.ts b/src/__tests__/driver/abstract-oracle-driver-easy-connect-string.test.ts index 434aea4..a014aa7 100644 --- a/src/__tests__/driver/abstract-oracle-driver-easy-connect-string.test.ts +++ b/src/__tests__/driver/abstract-oracle-driver-easy-connect-string.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { EasyConnectString } from "../../driver/abstract-oracle-driver/easy-connect-string"; @@ -50,31 +50,4 @@ describe("EasyConnectString", () => { "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=oracle.local)(PORT=1521))(CONNECT_DATA=(SID=ORCL)))", ); }); - - it("uses SERVICE_NAME mode and emits a deprecation warning for service", () => { - const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined); - - const easyConnect = EasyConnectString.fromConnectionParameters({ - host: "oracle.local", - dbname: "ORCLPDB1", - service: true, - instancename: "ORCL1", - pooled: true, - port: 2484, - driverOptions: { - protocol: "TCPS", - }, - }); - - expect(easyConnect.toString()).toBe( - "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCPS)(HOST=oracle.local)(PORT=2484))(CONNECT_DATA=(SERVICE_NAME=ORCLPDB1)(INSTANCE_NAME=ORCL1)(SERVER=POOLED)))", - ); - expect(emitWarning).toHaveBeenCalledTimes(1); - expect(emitWarning).toHaveBeenCalledWith( - expect.stringContaining('"service" parameter'), - "DeprecationWarning", - ); - - emitWarning.mockRestore(); - }); }); diff --git a/src/__tests__/driver/abstract-oracle-driver.test.ts b/src/__tests__/driver/abstract-oracle-driver.test.ts new file mode 100644 index 0000000..c4ca7e0 --- /dev/null +++ b/src/__tests__/driver/abstract-oracle-driver.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { StaticServerVersionProvider } from "../../connection/static-server-version-provider"; +import type { DriverConnection } from "../../driver"; +import { AbstractOracleDriver } from "../../driver/abstract-oracle-driver"; +import { ExceptionConverter as OCIExceptionConverter } from "../../driver/api/oci/exception-converter"; +import { OraclePlatform } from "../../platforms/oracle-platform"; + +class TestOracleDriver extends AbstractOracleDriver { + public async connect(_params: Record): Promise { + throw new Error("not used in this test"); + } +} + +describe("AbstractOracleDriver", () => { + it("returns OraclePlatform", () => { + const driver = new TestOracleDriver(); + + expect(driver.getDatabasePlatform(new StaticServerVersionProvider("19.0"))).toBeInstanceOf( + OraclePlatform, + ); + }); + + it("returns the OCI exception converter", () => { + const driver = new TestOracleDriver(); + + expect(driver.getExceptionConverter()).toBeInstanceOf(OCIExceptionConverter); + }); +}); diff --git a/src/__tests__/driver/driver-result-classes.test.ts b/src/__tests__/driver/driver-result-classes.test.ts new file mode 100644 index 0000000..bf04276 --- /dev/null +++ b/src/__tests__/driver/driver-result-classes.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { Result as MSSQLResult } from "../../driver/mssql/result"; +import { Result as MySQL2Result } from "../../driver/mysql2/result"; +import { Result as PgResult } from "../../driver/pg/result"; +import type { Result as DriverResult } from "../../driver/result"; +import { Result as SQLite3Result } from "../../driver/sqlite3/result"; + +type DriverResultCtor = new ( + rows: Array>, + columns?: string[], + affectedRowCount?: number | string, +) => DriverResult; + +const driverResultCtors: Array<[string, DriverResultCtor]> = [ + ["mysql2", MySQL2Result], + ["pg", PgResult], + ["sqlite3", SQLite3Result], + ["mssql", MSSQLResult], +]; + +describe("driver result classes", () => { + it.each(driverResultCtors)("%s result fetches rows and metadata", (_name, ResultCtor) => { + const result = new ResultCtor( + [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ], + ["id", "name"], + 2, + ); + + expect(result.fetchOne()).toBe(1); + expect(result.fetchNumeric<[number, string]>()).toEqual([2, "Bob"]); + expect(result.fetchAssociative()).toBe(false); + expect(result.rowCount()).toBe(2); + expect(result.columnCount()).toBe(2); + expect( + (result as DriverResult & { getColumnName: (index: number) => string }).getColumnName(1), + ).toBe("name"); + }); + + it.each( + driverResultCtors, + )("%s result supports fetchAll helpers and free()", (_name, ResultCtor) => { + const result = new ResultCtor( + [ + { id: 1, name: "A" }, + { id: 2, name: "B" }, + ], + ["id", "name"], + "3", + ); + + expect(result.fetchAllNumeric<[number, string]>()).toEqual([ + [1, "A"], + [2, "B"], + ]); + expect(result.rowCount()).toBe("3"); + + result.free(); + expect(result.fetchFirstColumn()).toEqual([]); + expect(result.rowCount()).toBe("3"); + }); +}); diff --git a/src/__tests__/logging/middleware.test.ts b/src/__tests__/logging/middleware.test.ts index ba2e3da..b8c1c24 100644 --- a/src/__tests__/logging/middleware.test.ts +++ b/src/__tests__/logging/middleware.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from "vitest"; import { Configuration } from "../../configuration"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverManager } from "../../driver-manager"; import { DriverException } from "../../exception/driver-exception"; import type { Logger } from "../../logging/logger"; diff --git a/src/__tests__/portability/middleware.test.ts b/src/__tests__/portability/middleware.test.ts index e5d1de9..595c24c 100644 --- a/src/__tests__/portability/middleware.test.ts +++ b/src/__tests__/portability/middleware.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from "vitest"; import { ColumnCase } from "../../column-case"; import { Configuration } from "../../configuration"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverManager } from "../../driver-manager"; import { DriverException } from "../../exception/driver-exception"; import { OraclePlatform } from "../../platforms/oracle-platform"; diff --git a/src/__tests__/query/query-builder.test.ts b/src/__tests__/query/query-builder.test.ts index f5d7415..3a41bb6 100644 --- a/src/__tests__/query/query-builder.test.ts +++ b/src/__tests__/query/query-builder.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from "vitest"; import { ArrayParameterType } from "../../array-parameter-type"; import { Connection } from "../../connection"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverException } from "../../exception/driver-exception"; import { ParameterType } from "../../parameter-type"; import { MySQLPlatform } from "../../platforms/mysql-platform"; @@ -120,7 +120,7 @@ class SpyExecutionConnection extends Connection { types: QueryParameterTypes = [], ): Promise { this.queryCalls.push({ params, sql, types }); - return new Result(new ArrayResult([...this.queryRows])); + return new Result(new ArrayResult([...this.queryRows]), this); } public override async executeStatement( diff --git a/src/__tests__/result/result.test.ts b/src/__tests__/result/result.test.ts index baed7ac..6071fdc 100644 --- a/src/__tests__/result/result.test.ts +++ b/src/__tests__/result/result.test.ts @@ -1,14 +1,28 @@ import { describe, expect, it } from "vitest"; +import type { Connection as DBALConnection } from "../../connection"; import { ArrayResult } from "../../driver/array-result"; +import type { Result as DriverResult } from "../../driver/result"; import { NoKeyValue } from "../../exception/no-key-value"; import { Result } from "../../result"; function expectUserRow(_row: { id: number; name: string } | false): void {} +const passthroughConnection = { + convertException(error: unknown): never { + throw error as Error; + }, +} as unknown as DBALConnection; + +function createResult = Record>( + driverResult: DriverResult, +): Result { + return new Result(driverResult, passthroughConnection); +} + describe("Result", () => { it("uses class-level row type for fetchAssociative() by default", () => { - const result = new Result<{ id: number; name: string }>( + const result = createResult<{ id: number; name: string }>( new ArrayResult([{ id: 1, name: "Alice" }]), ); @@ -18,7 +32,7 @@ describe("Result", () => { }); it("fetches associative rows sequentially", () => { - const result = new Result( + const result = createResult( new ArrayResult([ { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, @@ -31,7 +45,7 @@ describe("Result", () => { }); it("returns a clone when fetching associative rows", () => { - const result = new Result(new ArrayResult([{ id: 1, name: "Alice" }])); + const result = createResult(new ArrayResult([{ id: 1, name: "Alice" }])); const row = result.fetchAssociative<{ id: number; name: string }>(); expect(row).toEqual({ id: 1, name: "Alice" }); @@ -43,13 +57,13 @@ describe("Result", () => { }); it("fetches numeric rows using explicit column order", () => { - const result = new Result(new ArrayResult([{ id: 7, name: "Carol" }], ["name", "id"])); + const result = createResult(new ArrayResult([{ id: 7, name: "Carol" }], ["name", "id"])); expect(result.fetchNumeric<[string, number]>()).toEqual(["Carol", 7]); }); it("fetches single values and first column values", () => { - const result = new Result( + const result = createResult( new ArrayResult([ { id: 10, name: "A" }, { id: 20, name: "B" }, @@ -61,14 +75,14 @@ describe("Result", () => { }); it("fetches all numeric and associative rows", () => { - const resultForNumeric = new Result( + const resultForNumeric = createResult( new ArrayResult([ { id: 1, name: "A" }, { id: 2, name: "B" }, ]), ); - const resultForAssociative = new Result( + const resultForAssociative = createResult( new ArrayResult([ { id: 1, name: "A" }, { id: 2, name: "B" }, @@ -86,7 +100,7 @@ describe("Result", () => { }); it("fetches key/value pairs", () => { - const result = new Result( + const result = createResult( new ArrayResult([ { id: "one", value: 100, extra: "x" }, { id: "two", value: 200, extra: "y" }, @@ -100,13 +114,13 @@ describe("Result", () => { }); it("throws when key/value fetch has less than two columns", () => { - const result = new Result(new ArrayResult([{ id: 1 }])); + const result = createResult(new ArrayResult([{ id: 1 }])); expect(() => result.fetchAllKeyValue()).toThrow(NoKeyValue); }); it("fetches associative rows indexed by first column", () => { - const result = new Result( + const result = createResult( new ArrayResult([ { id: "u1", name: "Alice", active: true }, { id: "u2", name: "Bob", active: false }, @@ -120,7 +134,7 @@ describe("Result", () => { }); it("supports explicit row and column metadata", () => { - const result = new Result(new ArrayResult([], ["id", "name"], 42)); + const result = createResult(new ArrayResult([], ["id", "name"], 42)); expect(result.rowCount()).toBe(42); expect(result.columnCount()).toBe(2); @@ -129,10 +143,41 @@ describe("Result", () => { }); it("releases rows when free() is called", () => { - const result = new Result(new ArrayResult([{ id: 1 }])); + const result = createResult(new ArrayResult([{ id: 1 }])); result.free(); expect(result.fetchAssociative()).toBe(false); expect(result.rowCount()).toBe(0); }); + + it("converts driver exceptions using the connection", () => { + const driverError = new Error("driver failure"); + const convertedError = new Error("converted failure"); + const calls: Array<{ error: unknown; operation: string }> = []; + const connection = { + convertException(error: unknown, operation: string): Error { + calls.push({ error, operation }); + return convertedError; + }, + } as unknown as DBALConnection; + + const failingResult: DriverResult = { + fetchNumeric: () => { + throw driverError; + }, + fetchAssociative: () => false, + fetchOne: () => false, + fetchAllNumeric: () => [], + fetchAllAssociative: () => [], + fetchFirstColumn: () => [], + rowCount: () => 0, + columnCount: () => 0, + free: () => {}, + }; + + const result = new Result(failingResult, connection); + + expect(() => result.fetchNumeric()).toThrow(convertedError); + expect(calls).toEqual([{ error: driverError, operation: "fetchNumeric" }]); + }); }); diff --git a/src/__tests__/schema/schema-manager.test.ts b/src/__tests__/schema/schema-manager.test.ts index 7b0dad3..0fd0a0d 100644 --- a/src/__tests__/schema/schema-manager.test.ts +++ b/src/__tests__/schema/schema-manager.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from "vitest"; import { Configuration } from "../../configuration"; import { Connection } from "../../connection"; import { type Driver, type DriverConnection } from "../../driver"; +import { ParameterBindingStyle } from "../../driver/_internal"; import type { ExceptionConverter, ExceptionConverterContext, } from "../../driver/api/exception-converter"; import { ArrayResult } from "../../driver/array-result"; -import { ParameterBindingStyle } from "../../driver/internal-parameter-binding-style"; import { DriverException } from "../../exception/driver-exception"; import { MySQLPlatform } from "../../platforms/mysql-platform"; import { AbstractSchemaManager } from "../../schema/abstract-schema-manager"; diff --git a/src/__tests__/statement/statement.test.ts b/src/__tests__/statement/statement.test.ts index 2f28907..85333f7 100644 --- a/src/__tests__/statement/statement.test.ts +++ b/src/__tests__/statement/statement.test.ts @@ -1,131 +1,220 @@ import { describe, expect, it } from "vitest"; +import { Connection } from "../../connection"; +import { type Driver, type DriverConnection } from "../../driver"; +import type { + ExceptionConverter, + ExceptionConverterContext, +} from "../../driver/api/exception-converter"; +import { ArrayResult } from "../../driver/array-result"; +import type { Statement as DriverStatement } from "../../driver/statement"; +import { DriverException } from "../../exception/driver-exception"; import { ParameterType } from "../../parameter-type"; -import { Result } from "../../result"; -import { Statement, type StatementExecutor } from "../../statement"; -import type { QueryParameterTypes, QueryParameters } from "./query"; - -class SpyExecutor implements StatementExecutor { - public lastQueryCall: - | { params: QueryParameters | undefined; sql: string; types: QueryParameterTypes | undefined } - | undefined; - public lastStatementCall: - | { params: QueryParameters | undefined; sql: string; types: QueryParameterTypes | undefined } - | undefined; - - public async executeQuery( - sql: string, - params?: QueryParameters, - types?: QueryParameterTypes, - ): Promise { - this.lastQueryCall = { params, sql, types }; - return new Result({ rows: [{ ok: true }] }); +import { MySQLPlatform } from "../../platforms/mysql-platform"; +import { Statement } from "../../statement"; +import { DateType } from "../../types/date-type"; +import { registerBuiltInTypes } from "../../types/register-built-in-types"; +import { Types } from "../../types/types"; + +class NoopDriverConnection implements DriverConnection { + public async prepare(_sql: string): Promise { + throw new Error("not used"); } - public async executeStatement( - sql: string, - params?: QueryParameters, - types?: QueryParameterTypes, - ): Promise { - this.lastStatementCall = { params, sql, types }; - return 3; + public async query(_sql: string) { + throw new Error("not used"); + } + + public quote(value: string): string { + return `'${value}'`; + } + + public async exec(_sql: string): Promise { + throw new Error("not used"); + } + + public async lastInsertId(): Promise { + return 1; + } + + public async beginTransaction(): Promise {} + public async commit(): Promise {} + public async rollBack(): Promise {} + public async getServerVersion(): Promise { + return "1.0.0"; + } + public getNativeConnection(): unknown { + return this; + } +} + +class SpyExceptionConverter implements ExceptionConverter { + public lastContext: ExceptionConverterContext | undefined; + + public convert(error: unknown, context: ExceptionConverterContext): DriverException { + this.lastContext = context; + + return new DriverException("converted", { + cause: error, + driverName: "spy", + operation: context.operation, + parameters: context.query?.parameters, + sql: context.query?.sql, + }); + } +} + +class SpyDriver implements Driver { + constructor(private readonly converter: ExceptionConverter = new SpyExceptionConverter()) {} + + public async connect(_params: Record): Promise { + return new NoopDriverConnection(); + } + + public getExceptionConverter(): ExceptionConverter { + return this.converter; + } + + public getDatabasePlatform(): MySQLPlatform { + return new MySQLPlatform(); + } +} + +class SpyDriverStatement implements DriverStatement { + public readonly boundValues: Array<{ + param: string | number; + type: ParameterType | undefined; + value: unknown; + }> = []; + public bindError: unknown; + public executeError: unknown; + public executeResult = new ArrayResult([{ ok: true }], ["ok"], 1); + + public bindValue(param: string | number, value: unknown, type?: ParameterType): void { + if (this.bindError !== undefined) { + throw this.bindError; + } + + this.boundValues.push({ param, type, value }); + } + + public async execute() { + if (this.executeError !== undefined) { + throw this.executeError; + } + + return this.executeResult; } } describe("Statement", () => { - it("returns original SQL", () => { - const statement = new Statement(new SpyExecutor(), "SELECT 1"); + registerBuiltInTypes(); + + it("keeps SQL and exposes wrapped driver statement", () => { + const driverStatement = new SpyDriverStatement(); + const statement = new Statement( + new Connection({}, new SpyDriver()), + driverStatement, + "SELECT 1", + ); + expect(statement.getSQL()).toBe("SELECT 1"); + expect(statement.getWrappedStatement()).toBe(driverStatement); }); - it("binds positional values using 1-based index", async () => { - const executor = new SpyExecutor(); - const statement = new Statement(executor, "SELECT * FROM users WHERE id = ?"); + it("binds raw parameter types directly to the driver statement", () => { + const driverStatement = new SpyDriverStatement(); + const statement = new Statement( + new Connection({}, new SpyDriver()), + driverStatement, + "SELECT * FROM users WHERE id = ?", + ); - await statement.bindValue(1, 99, ParameterType.INTEGER).executeQuery(); + statement.bindValue(1, 99, ParameterType.INTEGER); - expect(executor.lastQueryCall).toEqual({ - params: [99], - sql: "SELECT * FROM users WHERE id = ?", - types: [ParameterType.INTEGER], - }); + expect(driverStatement.boundValues).toEqual([ + { param: 1, type: ParameterType.INTEGER, value: 99 }, + ]); }); - it("binds named values with and without colon prefix", async () => { - const executor = new SpyExecutor(); - const statement = new Statement(executor, "SELECT * FROM users WHERE id = :id"); + it("converts Datazen type names in bindValue() before driver binding", () => { + const driverStatement = new SpyDriverStatement(); + const statement = new Statement( + new Connection({}, new SpyDriver()), + driverStatement, + "SELECT :active", + ); - await statement - .bindValue(":id", 10, ParameterType.INTEGER) - .bindValue("status", "active", ParameterType.STRING) - .executeQuery(); + statement.bindValue(":active", true, Types.BOOLEAN); - expect(executor.lastQueryCall).toEqual({ - params: { id: 10, status: "active" }, - sql: "SELECT * FROM users WHERE id = :id", - types: { id: ParameterType.INTEGER, status: ParameterType.STRING }, - }); + expect(driverStatement.boundValues).toEqual([ + { param: ":active", type: ParameterType.BOOLEAN, value: 1 }, + ]); }); - it("sets positional parameters in bulk", async () => { - const executor = new SpyExecutor(); - const statement = new Statement(executor, "SELECT * FROM users WHERE id = ? AND role = ?"); + it("converts Datazen Type instances in bindValue() before driver binding", () => { + const driverStatement = new SpyDriverStatement(); + const statement = new Statement( + new Connection({}, new SpyDriver()), + driverStatement, + "SELECT ?", + ); - await statement - .setParameters([5, "admin"], [ParameterType.INTEGER, ParameterType.STRING]) - .executeQuery(); - - expect(executor.lastQueryCall).toEqual({ - params: [5, "admin"], - sql: "SELECT * FROM users WHERE id = ? AND role = ?", - types: [ParameterType.INTEGER, ParameterType.STRING], - }); - }); + statement.bindValue(1, new Date(2024, 0, 2), new DateType()); - it("sets named parameters in bulk", async () => { - const executor = new SpyExecutor(); - const statement = new Statement(executor, "SELECT * FROM users WHERE id = :id"); - - await statement - .setParameters( - { id: 7, role: "editor" }, - { id: ParameterType.INTEGER, role: ParameterType.STRING }, - ) - .executeQuery(); - - expect(executor.lastQueryCall).toEqual({ - params: { id: 7, role: "editor" }, - sql: "SELECT * FROM users WHERE id = :id", - types: { id: ParameterType.INTEGER, role: ParameterType.STRING }, - }); + expect(driverStatement.boundValues).toEqual([ + { param: 1, type: ParameterType.STRING, value: "2024-01-02" }, + ]); }); - it("supports mixed positional and named bindings", async () => { - const executor = new SpyExecutor(); - const mixed = new Statement(executor, "SELECT * FROM users WHERE id = :id AND parent_id = ?"); + it("wraps driver execute() result for executeQuery()", async () => { + const driverStatement = new SpyDriverStatement(); + driverStatement.executeResult = new ArrayResult([{ ok: true }], ["ok"], 1); + const statement = new Statement( + new Connection({}, new SpyDriver()), + driverStatement, + "SELECT 1 AS ok", + ); - await mixed.bindValue(1, 10).bindValue("id", 10).executeQuery(); + const result = await statement.executeQuery<{ ok: boolean }>(); - expect(executor.lastQueryCall).toEqual({ - params: { 0: 10, id: 10 }, - sql: "SELECT * FROM users WHERE id = :id AND parent_id = ?", - types: { 0: ParameterType.STRING, id: ParameterType.STRING }, - }); + expect(result.fetchAssociative()).toEqual({ ok: true }); }); - it("executes statement calls through executor", async () => { - const executor = new SpyExecutor(); - const statement = new Statement(executor, "UPDATE users SET name = ? WHERE id = ?"); + it("returns rowCount() from executeStatement()", async () => { + const driverStatement = new SpyDriverStatement(); + driverStatement.executeResult = new ArrayResult([], [], "3"); + const statement = new Statement( + new Connection({}, new SpyDriver()), + driverStatement, + "UPDATE users SET active = 1", + ); - const affectedRows = await statement - .setParameters(["Alice", 1], [ParameterType.STRING, ParameterType.INTEGER]) - .executeStatement(); + await expect(statement.executeStatement()).resolves.toBe("3"); + }); - expect(affectedRows).toBe(3); - expect(executor.lastStatementCall).toEqual({ - params: ["Alice", 1], - sql: "UPDATE users SET name = ? WHERE id = ?", - types: [ParameterType.STRING, ParameterType.INTEGER], - }); + it("converts driver execution errors with SQL, params, and original types", async () => { + const converter = new SpyExceptionConverter(); + const driverStatement = new SpyDriverStatement(); + driverStatement.executeError = new Error("driver execute failed"); + const statement = new Statement( + new Connection({}, new SpyDriver(converter)), + driverStatement, + "SELECT ?", + ); + + statement.bindValue(1, true, Types.BOOLEAN); + + await expect(statement.executeQuery()).rejects.toBeInstanceOf(DriverException); + expect(converter.lastContext?.operation).toBe("query"); + expect(converter.lastContext?.query?.sql).toBe("SELECT ?"); + + const parameters = converter.lastContext?.query?.parameters; + expect(Array.isArray(parameters)).toBe(true); + expect((parameters as unknown[])[1]).toBe(true); + + const types = converter.lastContext?.query?.types; + expect(Array.isArray(types)).toBe(true); + expect((types as unknown[])[1]).toBe(Types.BOOLEAN); }); }); diff --git a/src/__tests__/tools/porting.test.ts b/src/__tests__/tools/porting.test.ts new file mode 100644 index 0000000..42d0e9f --- /dev/null +++ b/src/__tests__/tools/porting.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; + +import { + CASE_LOWER, + CASE_UPPER, + array_change_key_case, + array_column, + array_fill, + array_key_exists, + assert, + is_bool, + is_boolean, + is_int, + is_string, + key, + method_exists, + version_compare, +} from "../../_internal"; + +describe("porting helpers", () => { + it("supports basic type guards", () => { + expect(is_int(1)).toBe(true); + expect(is_int(1.5)).toBe(false); + expect(is_string("x")).toBe(true); + expect(is_string(1)).toBe(false); + expect(is_boolean(true)).toBe(true); + expect(is_boolean("true")).toBe(false); + expect(is_bool(false)).toBe(true); + }); + + it("changes object key case", () => { + expect(array_change_key_case({ Foo: 1, BAR: 2 }, CASE_LOWER)).toEqual({ + foo: 1, + bar: 2, + }); + + expect(array_change_key_case({ Foo: 1, bar: 2 }, CASE_UPPER)).toEqual({ + FOO: 1, + BAR: 2, + }); + }); + + it("returns the first enumerable key", () => { + expect(key({ alpha: 1, beta: 2 })).toBe("alpha"); + expect(key({ "01": "x" })).toBe("01"); + + const sparse: unknown[] = []; + sparse[2] = "x"; + expect(key(sparse)).toBe(2); + + expect(key([])).toBeNull(); + }); + + it("checks array/object key existence (including undefined values)", () => { + expect(array_key_exists("a", { a: undefined })).toBe(true); + expect(array_key_exists("b", { a: undefined })).toBe(false); + + const values = ["x"]; + expect(array_key_exists(0, values)).toBe(true); + expect(array_key_exists(1, values)).toBe(false); + }); + + it("fills values starting at arbitrary indexes", () => { + expect(array_fill(0, 3, "x")).toEqual(["x", "x", "x"]); + + const offset = array_fill(2, 2, 7); + expect(offset.length).toBe(4); + expect(Object.keys(offset)).toEqual(["2", "3"]); + expect(offset[2]).toBe(7); + expect(offset[3]).toBe(7); + + const negative = array_fill(-2, 2, "v"); + expect(Object.keys(negative)).toEqual(["-2", "-1"]); + }); + + it("detects methods on instances and prototypes", () => { + class Example { + public run(): void {} + + public get computed(): string { + return "value"; + } + } + + const instance = new Example() as Example & { ownFn?: () => void }; + instance.ownFn = () => {}; + + expect(method_exists(instance, "run")).toBe(true); + expect(method_exists(instance, "ownFn")).toBe(true); + expect(method_exists(instance, "computed")).toBe(false); + expect(method_exists(instance, "missing")).toBe(false); + expect(method_exists(null, "run")).toBe(false); + }); + + it("extracts columns from object and array rows", () => { + expect( + array_column<{ id: number; name: string } | { id: number }>( + [{ id: 1, name: "a" }, { id: 2 }], + "name", + ), + ).toEqual(["a"]); + + expect( + array_column( + [ + { id: 10, name: "alice" }, + { id: 20, name: "bob" }, + ], + "name", + "id", + ), + ).toEqual({ + "10": "alice", + "20": "bob", + }); + + expect( + array_column( + [ + [11, "a"], + [12, "b"], + ], + 1, + 0, + ), + ).toEqual({ + "11": "a", + "12": "b", + }); + + expect(array_column([{ id: 1 }, { id: 2 }], null)).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it("throws on failed assert and keeps custom errors", () => { + expect(() => assert(true)).not.toThrow(); + expect(() => assert(false, "boom")).toThrow("boom"); + + const custom = new TypeError("typed"); + expect(() => assert(false, custom)).toThrow(TypeError); + }); + + it("compares versions with numeric and pre-release semantics", () => { + expect(version_compare("8.0.0", "8.0.0")).toBe(0); + expect(version_compare("8.0.1", "8.0.0")).toBe(1); + expect(version_compare("8.0.0", "8.0.1")).toBe(-1); + + expect(version_compare("8.0.0RC1", "8.0.0")).toBe(-1); + expect(version_compare("8.0.0", "8.0.0pl1")).toBe(-1); + expect(version_compare("8.0", "8.0.0")).toBe(0); + }); + + it("supports php-style version_compare operators", () => { + expect(version_compare("8.0.0", "8.0.0RC1", ">")).toBe(true); + expect(version_compare("8.0.0", "8.0.0", "eq")).toBe(true); + expect(version_compare("8.0.0", "8.0.1", "lt")).toBe(true); + expect(version_compare("8.0.1", "8.0.0", "ne")).toBe(true); + expect(version_compare("8.0.0", "8.0.0", "<>")).toBe(false); + }); +}); diff --git a/src/__tests__/types/types.test.ts b/src/__tests__/types/types.test.ts index 7c928d6..7459bef 100644 --- a/src/__tests__/types/types.test.ts +++ b/src/__tests__/types/types.test.ts @@ -2,13 +2,11 @@ import { describe, expect, it } from "vitest"; import { MySQLPlatform } from "../../platforms/mysql-platform"; import { DateTimeType } from "../../types/date-time-type"; -import { - SerializationFailed, - TypeAlreadyRegistered, - TypeNotRegistered, - TypesAlreadyExists, - UnknownColumnType, -} from "../../types/exception/index"; +import { SerializationFailed } from "../../types/exception/serialization-failed"; +import { TypeAlreadyRegistered } from "../../types/exception/type-already-registered"; +import { TypeNotRegistered } from "../../types/exception/type-not-registered"; +import { TypesAlreadyExists } from "../../types/exception/types-already-exists"; +import { UnknownColumnType } from "../../types/exception/unknown-column-type"; import { JsonType } from "../../types/json-type"; import { registerBuiltInTypes } from "../../types/register-built-in-types"; import { SimpleArrayType } from "../../types/simple-array-type"; diff --git a/src/_internal.ts b/src/_internal.ts new file mode 100644 index 0000000..386fc91 --- /dev/null +++ b/src/_internal.ts @@ -0,0 +1,351 @@ +import { coerce, compare, valid } from "semver"; + +export const CASE_LOWER = 0; +export const CASE_UPPER = 1; + +export type PortingArrayKey = string | number; + +type ColumnKey = string | number | null; + +export function is_int(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value); +} + +export function is_string(value: unknown): value is string { + return typeof value === "string"; +} + +export function is_boolean(value: unknown): value is boolean { + return typeof value === "boolean"; +} + +export function isset(value: unknown): boolean { + return value !== undefined && value !== null; +} + +export function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + + return prototype === Object.prototype || prototype === null; +} + +export function strval(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + + if (value === true) { + return "1"; + } + + if (value === false) { + return ""; + } + + return String(value); +} + +export function empty(value: unknown): boolean { + if (value === undefined || value === null) { + return true; + } + + if (value === false || value === 0 || value === "" || value === "0") { + return true; + } + + if (Array.isArray(value)) { + return value.length === 0; + } + + if (isPlainObject(value)) { + return Object.keys(value).length === 0; + } + + return false; +} + +export const is_bool = is_boolean; + +export function array_change_key_case( + input: Record, + caseMode: typeof CASE_LOWER | typeof CASE_UPPER = CASE_LOWER, +): Record { + const output: Record = {}; + + for (const [key, value] of Object.entries(input)) { + const normalizedKey = caseMode === CASE_LOWER ? key.toLowerCase() : key.toUpperCase(); + output[normalizedKey] = value; + } + + return output; +} + +export function key(value: unknown[] | Record): PortingArrayKey | null { + const keys = Object.keys(value); + if (keys.length === 0) { + return null; + } + + return toArrayKey(keys[0]); +} + +export function array_key_exists(keyValue: PortingArrayKey, value: unknown): boolean { + if (value === null || (typeof value !== "object" && !Array.isArray(value))) { + return false; + } + + return Object.hasOwn(value, String(keyValue)); +} + +export function array_fill(startIndex: number, count: number, value: T): T[] { + if (!Number.isInteger(startIndex)) { + throw new TypeError("array_fill(): startIndex must be an integer."); + } + + if (!Number.isInteger(count) || count < 0) { + throw new RangeError("array_fill(): count must be a non-negative integer."); + } + + const output: T[] = []; + + for (let offset = 0; offset < count; offset += 1) { + const index = startIndex + offset; + (output as Record)[String(index)] = value; + } + + return output; +} + +export function method_exists(value: unknown, methodName: string): boolean { + if ((typeof value !== "object" && typeof value !== "function") || value === null) { + return false; + } + + let current: object | null = value; + + while (current !== null) { + const descriptor = Object.getOwnPropertyDescriptor(current, methodName); + + if (descriptor !== undefined) { + if ("value" in descriptor) { + return typeof descriptor.value === "function"; + } + + return false; + } + + current = Object.getPrototypeOf(current); + } + + return false; +} + +export function array_column(input: unknown[], columnKey: ColumnKey): TColumn[]; +export function array_column( + input: unknown[], + columnKey: ColumnKey, + indexKey: ColumnKey, +): Record; +export function array_column( + input: unknown[], + columnKey: ColumnKey, + indexKey?: ColumnKey, +): TColumn[] | Record { + if (indexKey === undefined) { + const output: TColumn[] = []; + + for (const row of input) { + const columnValue = columnKey === null ? row : readColumnValue(row, columnKey); + + if (columnValue === missingColumnValue) { + continue; + } + + output.push(columnValue as TColumn); + } + + return output; + } + + const output: Record = {}; + let fallbackIndex = 0; + + for (const row of input) { + const columnValue = columnKey === null ? row : readColumnValue(row, columnKey); + + if (columnValue === missingColumnValue) { + continue; + } + + const rawIndex = indexKey === null ? missingColumnValue : readColumnValue(row, indexKey); + const resolvedIndex = + rawIndex === missingColumnValue ? String(fallbackIndex++) : String(rawIndex); + + output[resolvedIndex] = columnValue as TColumn; + } + + return output; +} + +export function assert(condition: unknown, message?: string | Error): asserts condition { + if (condition) { + return; + } + + if (message instanceof Error) { + throw message; + } + + throw new Error(message ?? "Assertion failed"); +} + +export type VersionCompareOperator = + | "<" + | "lt" + | "<=" + | "le" + | ">" + | "gt" + | ">=" + | "ge" + | "==" + | "=" + | "eq" + | "!=" + | "<>" + | "ne"; + +export function version_compare(version1: string, version2: string): -1 | 0 | 1; +export function version_compare( + version1: string, + version2: string, + operator: VersionCompareOperator, +): boolean; +export function version_compare( + version1: string, + version2: string, + operator?: VersionCompareOperator, +): boolean | -1 | 0 | 1 { + const comparison = comparePhpLikeVersions(version1, version2); + + if (operator === undefined) { + return comparison; + } + + switch (operator) { + case "<": + case "lt": + return comparison < 0; + case "<=": + case "le": + return comparison <= 0; + case ">": + case "gt": + return comparison > 0; + case ">=": + case "ge": + return comparison >= 0; + case "==": + case "=": + case "eq": + return comparison === 0; + case "!=": + case "<>": + case "ne": + return comparison !== 0; + } +} + +function toArrayKey(value: string): PortingArrayKey { + if (/^(0|-?[1-9]\d*)$/.test(value)) { + const parsed = Number(value); + + if (Number.isSafeInteger(parsed)) { + return parsed; + } + } + + return value; +} + +const missingColumnValue = Symbol("missingColumnValue"); + +function readColumnValue(row: unknown, columnKey: Exclude): unknown { + if (Array.isArray(row)) { + const normalizedKey = + typeof columnKey === "number" ? columnKey : Number.parseInt(columnKey, 10); + + if (Number.isNaN(normalizedKey)) { + return missingColumnValue; + } + + return Object.hasOwn(row, normalizedKey) ? row[normalizedKey] : missingColumnValue; + } + + if (row === null || typeof row !== "object") { + return missingColumnValue; + } + + const record = row as Record; + const keyName = String(columnKey); + + return Object.hasOwn(record, keyName) ? record[keyName] : missingColumnValue; +} + +function comparePhpLikeVersions(left: string, right: string): -1 | 0 | 1 { + const leftVersion = normalizePhpVersionToSemver(left); + const rightVersion = normalizePhpVersionToSemver(right); + const result = compare(leftVersion, rightVersion); + + if (result < 0) { + return -1; + } + + if (result > 0) { + return 1; + } + + return 0; +} + +function normalizePhpVersionToSemver(input: string): string { + const direct = valid(input); + if (direct !== null) { + return direct; + } + + const base = coerce(input)?.version ?? "0.0.0"; + const baseMatch = input.match(/\d+(?:\.\d+){0,2}/); + const suffix = + baseMatch === null ? "" : input.slice(baseMatch.index! + baseMatch[0].length).trim(); + const suffixMatch = /^(?