Skip to content

Commit f568a78

Browse files
committed
feat!: unify query builder execution
1 parent 18a4da8 commit f568a78

55 files changed

Lines changed: 575 additions & 689 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/database/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# @ngandu-dev/database
22

3+
## 3.0.2
4+
5+
### Changed
6+
7+
- Replaced duplicated query models, binding enums, SQL builders, and platform exceptions with compatibility re-exports from `@ngandu-dev/query-builder`.
8+
- Made `Connection` and `AbstractPlatform` explicitly implement the standalone query-builder contracts.
9+
- Expanded `@ngandu-dev/database/query` to expose the canonical query model and expression symbols.
10+
- Added `ConnectedQueryBuilder`, a compatibility façade that extends the standalone builder while delegating all execution and fetch operations to `Connection`.
11+
- Allowed `Connection#executeQuery()` and `Connection#executeStatement()` to execute standalone query-builder instances after resolving the active database platform.
12+
13+
### Fixed
14+
15+
- Fixed array expansion when bindings originate from the standalone query-builder package.
16+
- Unified DB2, MySQL, and SQL Server locking behavior across the standalone and database-backed query surfaces.
17+
318
## 2.0.0
419

520
### Changed

packages/database/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ const users = await connection
5454

5555
Detailed guides are available in [`docs/`](docs), including configuration, transactions, platforms, portability, types, and schema support.
5656

57+
`connection.createQueryBuilder()` returns a connected builder. It has the same construction API as `@ngandu-dev/query-builder`, plus the legacy execution and fetch helpers. Those helpers are thin delegates: `Connection` remains the only component that connects, resolves the database platform, binds parameters, executes SQL, and converts driver errors.
58+
59+
For construction without a connection, import `StandaloneQueryBuilder` from `@ngandu-dev/database/query` or import `QueryBuilder` directly from `@ngandu-dev/query-builder`.
60+
5761
## Development
5862

5963
From the monorepo root:

packages/database/docs/query-builder.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,29 @@ const conn = DriverManager.getConnection({ driver: "mysql2", pool });
1414
const qb = conn.createQueryBuilder();
1515
```
1616

17+
Builder Variants
18+
----------------
19+
20+
`conn.createQueryBuilder()` returns a connected builder for application code. It
21+
extends the standalone builder and preserves the legacy execution and fetch API,
22+
but delegates every database operation to `Connection`. There is only one
23+
connection, parameter-binding, execution, and error-conversion implementation.
24+
25+
For SQL construction without I/O, use the standalone variant:
26+
27+
```ts
28+
import { StandaloneQueryBuilder } from "@ngandu-dev/database/query";
29+
30+
const query = new StandaloneQueryBuilder()
31+
.select("id", "email")
32+
.from("users");
33+
34+
console.log(query.getSQL());
35+
```
36+
37+
The standalone builder has no execution or fetch methods. It can also be
38+
imported as `QueryBuilder` from `@ngandu-dev/query-builder`.
39+
1740
Security: Preventing SQL Injection
1841
----------------------------------
1942

@@ -260,6 +283,13 @@ Execution API
260283
- `fetchAllAssociativeIndexed()` (async)
261284
- `fetchFirstColumn()` (async)
262285

286+
These methods exist only on the connected builder returned by
287+
`Connection#createQueryBuilder()`. They delegate to `Connection`, which first
288+
connects and resolves any server-version-dependent platform before compiling
289+
and executing the query. Calling synchronous `getSQL()` directly on such a
290+
builder requires the connection platform to already be available; execution
291+
methods resolve it automatically.
292+
263293
Not Implemented
264294
---------------
265295

packages/database/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ngandu-dev/database",
3-
"version": "2.0.0",
3+
"version": "3.0.2",
44
"description": "A TypeScript database abstraction layer inspired by Doctrine DBAL.",
55
"license": "MIT",
66
"author": "Bernard Ngandu <bernard@ngandu.dev>",
@@ -145,7 +145,7 @@
145145
"sql-server"
146146
],
147147
"dependencies": {
148-
"@ngandu-dev/query-builder": "^2.0.0",
148+
"@ngandu-dev/query-builder": "^3.0.2",
149149
"semver": "^7.7.4"
150150
},
151151
"peerDependencies": {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
ArrayParameterType as BuilderArrayParameterType,
3+
ConflictResolutionMode as BuilderConflictResolutionMode,
4+
DB2Platform as BuilderDB2Platform,
5+
DefaultSelectSQLBuilder as BuilderDefaultSelectSQLBuilder,
6+
MySQL80Platform as BuilderMySQL80Platform,
7+
MySQLPlatform as BuilderMySQLPlatform,
8+
NotSupported as BuilderNotSupported,
9+
ParameterType as BuilderParameterType,
10+
SelectQuery as BuilderSelectQuery,
11+
SQLServerPlatform as BuilderSQLServerPlatform,
12+
UnionType as BuilderUnionType,
13+
QueryBuilder,
14+
} from "@ngandu-dev/query-builder";
15+
16+
import { ArrayParameterType } from "../../array-parameter-type";
17+
import { ExpandArrayParameters } from "../../expand-array-parameters";
18+
import { ParameterType } from "../../parameter-type";
19+
import { DB2Platform } from "../../platforms/db2-platform";
20+
import { NotSupported } from "../../platforms/exception/not-supported";
21+
import { MySQLPlatform } from "../../platforms/mysql-platform";
22+
import { MySQL80Platform } from "../../platforms/mysql80-platform";
23+
import { SQLServerPlatform } from "../../platforms/sqlserver-platform";
24+
import { ConflictResolutionMode } from "../../query/for-update/conflict-resolution-mode";
25+
import { SelectQuery } from "../../query/select-query";
26+
import { UnionType } from "../../query/union-type";
27+
import { DefaultSelectSQLBuilder } from "../../sql/builder/default-select-sql-builder";
28+
import { Parser } from "../../sql/parser";
29+
30+
const buildLockedSelect = (
31+
platform: ConstructorParameters<typeof QueryBuilder>[0],
32+
mode: BuilderConflictResolutionMode = BuilderConflictResolutionMode.ORDINARY,
33+
): string => new QueryBuilder(platform).select("*").from("items").forUpdate(mode).getSQL();
34+
35+
describe("query-builder package contract", () => {
36+
it("uses one canonical set of public query and binding symbols", () => {
37+
expect(ArrayParameterType).toBe(BuilderArrayParameterType);
38+
expect(ParameterType).toBe(BuilderParameterType);
39+
expect(ConflictResolutionMode).toBe(BuilderConflictResolutionMode);
40+
expect(UnionType).toBe(BuilderUnionType);
41+
expect(SelectQuery).toBe(BuilderSelectQuery);
42+
expect(DefaultSelectSQLBuilder).toBe(BuilderDefaultSelectSQLBuilder);
43+
expect(NotSupported).toBe(BuilderNotSupported);
44+
});
45+
46+
it("expands arrays created with the standalone package binding enum", () => {
47+
const visitor = new ExpandArrayParameters(
48+
{ ids: [1, 2, 3] },
49+
{ ids: BuilderArrayParameterType.INTEGER },
50+
);
51+
52+
new Parser().parse("id IN (:ids)", visitor);
53+
54+
expect(visitor.getSQL()).toBe("id IN (?, ?, ?)");
55+
expect(visitor.getParameters()).toEqual([1, 2, 3]);
56+
expect(visitor.getTypes()).toEqual([
57+
ParameterType.INTEGER,
58+
ParameterType.INTEGER,
59+
ParameterType.INTEGER,
60+
]);
61+
});
62+
63+
it("generates identical DB2 locking SQL through both platform surfaces", () => {
64+
expect(buildLockedSelect(new BuilderDB2Platform())).toBe(buildLockedSelect(new DB2Platform()));
65+
expect(buildLockedSelect(new DB2Platform())).toBe(
66+
"SELECT * FROM items WITH RR USE AND KEEP UPDATE LOCKS",
67+
);
68+
69+
expect(() =>
70+
buildLockedSelect(new BuilderDB2Platform(), BuilderConflictResolutionMode.SKIP_LOCKED),
71+
).toThrow(BuilderNotSupported);
72+
});
73+
74+
it("generates identical SQL Server locking SQL through both platform surfaces", () => {
75+
expect(buildLockedSelect(new BuilderSQLServerPlatform())).toBe(
76+
buildLockedSelect(new SQLServerPlatform()),
77+
);
78+
expect(
79+
buildLockedSelect(new BuilderSQLServerPlatform(), BuilderConflictResolutionMode.SKIP_LOCKED),
80+
).toBe("SELECT * FROM items WITH (UPDLOCK, ROWLOCK, READPAST)");
81+
});
82+
83+
it("keeps version-aware MySQL locking behavior aligned", () => {
84+
expect(() =>
85+
buildLockedSelect(new BuilderMySQLPlatform(), BuilderConflictResolutionMode.SKIP_LOCKED),
86+
).toThrow(BuilderNotSupported);
87+
expect(() =>
88+
buildLockedSelect(new MySQLPlatform(), BuilderConflictResolutionMode.SKIP_LOCKED),
89+
).toThrow(BuilderNotSupported);
90+
91+
expect(
92+
buildLockedSelect(new BuilderMySQL80Platform(), BuilderConflictResolutionMode.SKIP_LOCKED),
93+
).toBe(buildLockedSelect(new MySQL80Platform(), BuilderConflictResolutionMode.SKIP_LOCKED));
94+
});
95+
});

packages/database/src/__tests__/query/query-builder.test.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { Connection as DriverConnection } from "../../driver/connection";
1313
import { DriverException } from "../../exception/driver-exception";
1414
import { ParameterType } from "../../parameter-type";
1515
import { MySQLPlatform } from "../../platforms/mysql-platform";
16+
import { MySQL80Platform } from "../../platforms/mysql80-platform";
1617
import type { QueryParameters, QueryParameterTypes } from "../../query";
1718
import { NonUniqueAlias } from "../../query/exception/non-unique-alias";
1819
import { UnknownAlias } from "../../query/exception/unknown-alias";
@@ -29,6 +30,7 @@ import { Union } from "../../query/union";
2930
import { UnionQuery } from "../../query/union-query";
3031
import { UnionType } from "../../query/union-type";
3132
import { Result } from "../../result";
33+
import type { ServerVersionProvider } from "../../server-version-provider";
3234

3335
describe("Query API surface parity", () => {
3436
it("adds SelectQuery getters and distinct flag accessor", () => {
@@ -121,14 +123,17 @@ class NoopExceptionConverter implements ExceptionConverter {
121123
}
122124

123125
class NoopDriverConnection implements DriverConnection {
126+
public readonly queries: string[] = [];
127+
124128
public async prepare(_sql: string) {
125129
return {
126130
bindValue: () => undefined,
127131
execute: async () => new ArrayResult([], [], 0),
128132
};
129133
}
130134

131-
public async query(_sql: string) {
135+
public async query(sql: string) {
136+
this.queries.push(sql);
132137
return new ArrayResult([], [], 0);
133138
}
134139

@@ -174,11 +179,28 @@ class NoopDriver implements Driver {
174179
return this.exceptionConverter;
175180
}
176181

177-
public getDatabasePlatform(): MySQLPlatform {
182+
public getDatabasePlatform(
183+
_versionProvider: ServerVersionProvider,
184+
): MySQLPlatform | Promise<MySQLPlatform> {
178185
return new MySQLPlatform();
179186
}
180187
}
181188

189+
class LazyPlatformDriver extends NoopDriver {
190+
public readonly connection = new NoopDriverConnection();
191+
192+
public override async connect(_params: Record<string, unknown>): Promise<DriverConnection> {
193+
return this.connection;
194+
}
195+
196+
public override async getDatabasePlatform(
197+
versionProvider: ServerVersionProvider,
198+
): Promise<MySQLPlatform> {
199+
await versionProvider.getServerVersion();
200+
return new MySQL80Platform();
201+
}
202+
}
203+
182204
class SpyExecutionConnection extends Connection {
183205
public readonly queryCalls: Array<{
184206
params: QueryParameters;
@@ -205,19 +227,33 @@ class SpyExecutionConnection extends Connection {
205227
}
206228

207229
public override async executeQuery(
208-
sql: string,
230+
queryOrSql: QueryBuilder | string,
209231
params: QueryParameters = [],
210232
types: QueryParameterTypes = [],
211233
): Promise<Result> {
234+
const sql =
235+
typeof queryOrSql === "string" ? queryOrSql : queryOrSql.getSQL(this.getDatabasePlatform());
236+
if (typeof queryOrSql !== "string") {
237+
params = queryOrSql.getParameters();
238+
types = queryOrSql.getParameterTypes();
239+
}
240+
212241
this.queryCalls.push({ params, sql, types });
213242
return new Result(new ArrayResult([...this.queryRows]), this);
214243
}
215244

216245
public override async executeStatement(
217-
sql: string,
246+
queryOrSql: QueryBuilder | string,
218247
params: QueryParameters = [],
219248
types: QueryParameterTypes = [],
220249
): Promise<number> {
250+
const sql =
251+
typeof queryOrSql === "string" ? queryOrSql : queryOrSql.getSQL(this.getDatabasePlatform());
252+
if (typeof queryOrSql !== "string") {
253+
params = queryOrSql.getParameters();
254+
types = queryOrSql.getParameterTypes();
255+
}
256+
221257
this.statementCalls.push({ params, sql, types });
222258
return this.statementResult;
223259
}
@@ -1105,6 +1141,19 @@ describe("QueryBuilder", () => {
11051141
]);
11061142
});
11071143

1144+
it("should resolve an asynchronous platform before executing a connected builder", async () => {
1145+
const driver = new LazyPlatformDriver();
1146+
const connection = new Connection({}, driver);
1147+
const qb = connection.createQueryBuilder();
1148+
1149+
qb.select("u.id").from("users", "u").forUpdate(ConflictResolutionMode.SKIP_LOCKED);
1150+
1151+
await qb.executeQuery();
1152+
1153+
expect(connection.getDatabasePlatform()).toBeInstanceOf(MySQL80Platform);
1154+
expect(driver.connection.queries).toEqual(["SELECT u.id FROM users u FOR UPDATE SKIP LOCKED"]);
1155+
});
1156+
11081157
it("should fetch associative through connection", async () => {
11091158
const connection = new SpyExecutionConnection({
11101159
queryRows: [{ id: 1, name: "Alice" }],
Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1 @@
1-
import { ParameterType } from "./parameter-type";
2-
3-
export enum ArrayParameterType {
4-
INTEGER = "ARRAY_INTEGER",
5-
STRING = "ARRAY_STRING",
6-
ASCII = "ARRAY_ASCII",
7-
BINARY = "ARRAY_BINARY",
8-
}
9-
10-
export namespace ArrayParameterType {
11-
export function toElementParameterType(type: ArrayParameterType): ParameterType {
12-
switch (type) {
13-
case ArrayParameterType.INTEGER:
14-
return ParameterType.INTEGER;
15-
case ArrayParameterType.STRING:
16-
return ParameterType.STRING;
17-
case ArrayParameterType.ASCII:
18-
return ParameterType.ASCII;
19-
case ArrayParameterType.BINARY:
20-
return ParameterType.BINARY;
21-
}
22-
}
23-
}
1+
export { ArrayParameterType } from "@ngandu-dev/query-builder";

0 commit comments

Comments
 (0)