tina4-nodejs 3.8.4 → 3.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -4
- package/packages/core/src/server.ts +36 -11
- package/packages/core/src/sessionHandlers/databaseHandler.ts +3 -12
- package/packages/core/src/types.ts +10 -0
- package/packages/orm/src/adapters/sqlite.ts +38 -92
- package/packages/orm/src/baseModel.ts +13 -0
- package/packages/orm/src/index.ts +1 -0
- package/packages/orm/src/queryBuilder.ts +299 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
|
|
6
6
|
"keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
|
|
@@ -49,9 +49,7 @@
|
|
|
49
49
|
"engines": {
|
|
50
50
|
"node": ">=20.0.0"
|
|
51
51
|
},
|
|
52
|
-
"dependencies": {
|
|
53
|
-
"better-sqlite3": "^11.0.0"
|
|
54
|
-
},
|
|
52
|
+
"dependencies": {},
|
|
55
53
|
"devDependencies": {
|
|
56
54
|
"typescript": "^5.7.0",
|
|
57
55
|
"tsx": "^4.19.0",
|
|
@@ -568,6 +568,28 @@ ${reset}
|
|
|
568
568
|
const req = createRequest(rawReq);
|
|
569
569
|
const res = createResponse(rawRes);
|
|
570
570
|
|
|
571
|
+
// Auto-start session — read cookie, create session, save + set cookie on response end
|
|
572
|
+
{
|
|
573
|
+
const { Session } = await import("./session.js");
|
|
574
|
+
const cookieHeader = rawReq.headers.cookie ?? "";
|
|
575
|
+
const sidMatch = cookieHeader.match(/tina4_session=([^;]+)/);
|
|
576
|
+
const existingSid = sidMatch ? sidMatch[1] : undefined;
|
|
577
|
+
const sess = new Session();
|
|
578
|
+
sess.start(existingSid);
|
|
579
|
+
(req as any).session = sess;
|
|
580
|
+
|
|
581
|
+
const origEnd = rawRes.end.bind(rawRes);
|
|
582
|
+
rawRes.end = function (...args: any[]) {
|
|
583
|
+
sess.save();
|
|
584
|
+
const newSid = (sess as any).sessionId ?? (sess as any).getSessionId?.();
|
|
585
|
+
if (newSid && newSid !== existingSid && !rawRes.headersSent) {
|
|
586
|
+
const ttl = parseInt(process.env.TINA4_SESSION_TTL ?? "3600", 10);
|
|
587
|
+
rawRes.setHeader("Set-Cookie", `tina4_session=${newSid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${ttl}`);
|
|
588
|
+
}
|
|
589
|
+
return origEnd(...args);
|
|
590
|
+
} as typeof rawRes.end;
|
|
591
|
+
}
|
|
592
|
+
|
|
571
593
|
// Add res.render() if Frond is available
|
|
572
594
|
if (frondEngine) {
|
|
573
595
|
res.render = (template: string, data?: Record<string, unknown>, statusCode?: number) => {
|
|
@@ -661,20 +683,23 @@ ${reset}
|
|
|
661
683
|
if (!proceed || res.raw.writableEnded) return;
|
|
662
684
|
}
|
|
663
685
|
|
|
664
|
-
//
|
|
665
|
-
// When 1 param: if named request/req, pass request; otherwise pass response
|
|
686
|
+
// Inject path params by name into handler arguments, then request/response
|
|
666
687
|
let result: unknown;
|
|
667
|
-
|
|
688
|
+
const routeParams = req.params || {};
|
|
689
|
+
const fnStr = match.handler.toString();
|
|
690
|
+
const argMatch = fnStr.match(/^(?:async\s+)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
|
|
691
|
+
const argNames = argMatch?.[1]?.split(",").map((s: string) => s.trim().replace(/[:=].*/,"")) ?? [];
|
|
692
|
+
const filteredArgs = argNames.filter((n: string) => n.length > 0);
|
|
693
|
+
|
|
694
|
+
if (filteredArgs.length === 0) {
|
|
668
695
|
result = await (match.handler as any)();
|
|
669
|
-
} else if (match.handler.length === 1) {
|
|
670
|
-
const fnStr = match.handler.toString();
|
|
671
|
-
const paramMatch = fnStr.match(/^(?:async\s+)?(?:function\s*)?\(?\s*(\w+)/);
|
|
672
|
-
const paramName = paramMatch?.[1]?.toLowerCase() ?? "";
|
|
673
|
-
result = (paramName === "request" || paramName === "req")
|
|
674
|
-
? await match.handler(req as any)
|
|
675
|
-
: await match.handler(res as any);
|
|
676
696
|
} else {
|
|
677
|
-
|
|
697
|
+
const args = filteredArgs.map((name: string) => {
|
|
698
|
+
if (name in routeParams) return routeParams[name];
|
|
699
|
+
if (name === "request" || name === "req") return req;
|
|
700
|
+
return res;
|
|
701
|
+
});
|
|
702
|
+
result = await (match.handler as any)(...args);
|
|
678
703
|
}
|
|
679
704
|
|
|
680
705
|
// If the route exports a template and the handler returned a plain object,
|
|
@@ -39,18 +39,9 @@ export class DatabaseSessionHandler implements SessionHandler {
|
|
|
39
39
|
constructor(config?: DatabaseSessionConfig) {
|
|
40
40
|
const dbPath = config?.dbPath ?? this.resolveDbPath();
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
} catch {
|
|
46
|
-
throw new Error(
|
|
47
|
-
"DatabaseSessionHandler requires 'better-sqlite3'. " +
|
|
48
|
-
"Install it with: npm install better-sqlite3"
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
this.db = new Database(dbPath);
|
|
53
|
-
this.db.pragma("journal_mode = WAL");
|
|
42
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
43
|
+
this.db = new DatabaseSync(dbPath);
|
|
44
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
54
45
|
}
|
|
55
46
|
|
|
56
47
|
/**
|
|
@@ -8,12 +8,22 @@ export interface UploadedFile {
|
|
|
8
8
|
size: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export interface Tina4Session {
|
|
12
|
+
get(key: string, defaultValue?: unknown): unknown;
|
|
13
|
+
set(key: string, value: unknown): void;
|
|
14
|
+
delete(key: string): void;
|
|
15
|
+
clear(): void;
|
|
16
|
+
save(): void;
|
|
17
|
+
readonly id: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
export interface Tina4Request extends IncomingMessage {
|
|
12
21
|
params: Record<string, string>;
|
|
13
22
|
query: Record<string, string>;
|
|
14
23
|
body: unknown;
|
|
15
24
|
ip: string;
|
|
16
25
|
files: UploadedFile[];
|
|
26
|
+
session: Tina4Session;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
export interface CookieOptions {
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
|
|
5
5
|
|
|
6
6
|
export class SQLiteAdapter implements DatabaseAdapter {
|
|
7
|
-
private db:
|
|
7
|
+
private db: DatabaseSync;
|
|
8
8
|
private _lastInsertId: number | bigint | null = null;
|
|
9
9
|
|
|
10
10
|
constructor(dbPath: string) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
this.db = new
|
|
15
|
-
this.db.
|
|
16
|
-
this.db.
|
|
11
|
+
if (dbPath !== ":memory:") {
|
|
12
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
this.db = new DatabaseSync(dbPath);
|
|
15
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
16
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
execute(sql: string, params?: unknown[]): unknown {
|
|
@@ -30,8 +30,9 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
30
30
|
let totalAffected = 0;
|
|
31
31
|
let lastId: number | bigint | undefined;
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
this.db.exec("BEGIN TRANSACTION");
|
|
34
|
+
try {
|
|
35
|
+
for (const params of paramsList) {
|
|
35
36
|
const result = stmt.run(...params);
|
|
36
37
|
totalAffected += result.changes;
|
|
37
38
|
if (result.lastInsertRowid) {
|
|
@@ -39,9 +40,12 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
39
40
|
this._lastInsertId = result.lastInsertRowid;
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
+
this.db.exec("COMMIT");
|
|
44
|
+
} catch (e) {
|
|
45
|
+
this.db.exec("ROLLBACK");
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
43
48
|
|
|
44
|
-
runMany(paramsList);
|
|
45
49
|
return { totalAffected, lastInsertId: lastId };
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -68,7 +72,6 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
|
|
71
|
-
// Handle list of rows — batch insert
|
|
72
75
|
if (Array.isArray(data)) {
|
|
73
76
|
if (data.length === 0) return { success: true, rowsAffected: 0 };
|
|
74
77
|
const keys = Object.keys(data[0]);
|
|
@@ -87,17 +90,9 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
87
90
|
try {
|
|
88
91
|
const result = this.db.prepare(sql).run(...values);
|
|
89
92
|
this._lastInsertId = result.lastInsertRowid;
|
|
90
|
-
return {
|
|
91
|
-
success: true,
|
|
92
|
-
rowsAffected: result.changes,
|
|
93
|
-
lastInsertId: result.lastInsertRowid,
|
|
94
|
-
};
|
|
93
|
+
return { success: true, rowsAffected: result.changes, lastInsertId: result.lastInsertRowid };
|
|
95
94
|
} catch (e) {
|
|
96
|
-
return {
|
|
97
|
-
success: false,
|
|
98
|
-
rowsAffected: 0,
|
|
99
|
-
error: (e as Error).message,
|
|
100
|
-
};
|
|
95
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
101
96
|
}
|
|
102
97
|
}
|
|
103
98
|
|
|
@@ -116,7 +111,6 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
116
111
|
}
|
|
117
112
|
|
|
118
113
|
delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult {
|
|
119
|
-
// Array of objects — delete each row
|
|
120
114
|
if (Array.isArray(filter)) {
|
|
121
115
|
let totalAffected = 0;
|
|
122
116
|
for (const row of filter) {
|
|
@@ -126,7 +120,6 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
126
120
|
return { success: true, rowsAffected: totalAffected };
|
|
127
121
|
}
|
|
128
122
|
|
|
129
|
-
// String filter — raw WHERE clause
|
|
130
123
|
if (typeof filter === "string") {
|
|
131
124
|
const sql = filter ? `DELETE FROM "${table}" WHERE ${filter}` : `DELETE FROM "${table}"`;
|
|
132
125
|
try {
|
|
@@ -137,7 +130,6 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
137
130
|
}
|
|
138
131
|
}
|
|
139
132
|
|
|
140
|
-
// Object filter — build WHERE from keys
|
|
141
133
|
const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
|
|
142
134
|
const sql = `DELETE FROM "${table}" WHERE ${whereClauses}`;
|
|
143
135
|
const values = Object.values(filter);
|
|
@@ -150,17 +142,9 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
150
142
|
}
|
|
151
143
|
}
|
|
152
144
|
|
|
153
|
-
startTransaction(): void {
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
commit(): void {
|
|
158
|
-
this.db.exec("COMMIT");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
rollback(): void {
|
|
162
|
-
this.db.exec("ROLLBACK");
|
|
163
|
-
}
|
|
145
|
+
startTransaction(): void { this.db.exec("BEGIN TRANSACTION"); }
|
|
146
|
+
commit(): void { this.db.exec("COMMIT"); }
|
|
147
|
+
rollback(): void { this.db.exec("ROLLBACK"); }
|
|
164
148
|
|
|
165
149
|
tables(): string[] {
|
|
166
150
|
const rows = this.query<{ name: string }>(
|
|
@@ -171,95 +155,57 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
171
155
|
|
|
172
156
|
columns(table: string): ColumnInfo[] {
|
|
173
157
|
const rows = this.db.prepare(`PRAGMA table_info("${table}")`).all() as Array<{
|
|
174
|
-
name: string;
|
|
175
|
-
type: string;
|
|
176
|
-
notnull: number;
|
|
177
|
-
dflt_value: unknown;
|
|
178
|
-
pk: number;
|
|
158
|
+
name: string; type: string; notnull: number; dflt_value: unknown; pk: number;
|
|
179
159
|
}>;
|
|
180
160
|
return rows.map((r) => ({
|
|
181
|
-
name: r.name,
|
|
182
|
-
type: r.type,
|
|
183
|
-
nullable: r.notnull === 0,
|
|
184
|
-
default: r.dflt_value,
|
|
185
|
-
primaryKey: r.pk === 1,
|
|
161
|
+
name: r.name, type: r.type, nullable: r.notnull === 0, default: r.dflt_value, primaryKey: r.pk === 1,
|
|
186
162
|
}));
|
|
187
163
|
}
|
|
188
164
|
|
|
189
|
-
lastInsertId(): number | bigint | null {
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
close(): void {
|
|
194
|
-
this.db.close();
|
|
195
|
-
}
|
|
165
|
+
lastInsertId(): number | bigint | null { return this._lastInsertId; }
|
|
166
|
+
close(): void { this.db.close(); }
|
|
196
167
|
|
|
197
168
|
tableExists(name: string): boolean {
|
|
198
|
-
const result = this.db
|
|
199
|
-
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
|
200
|
-
.get(name);
|
|
169
|
+
const result = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
201
170
|
return !!result;
|
|
202
171
|
}
|
|
203
172
|
|
|
204
173
|
createTable(name: string, columns: Record<string, FieldDefinition>): void {
|
|
205
174
|
const colDefs: string[] = [];
|
|
206
|
-
|
|
207
175
|
for (const [colName, def] of Object.entries(columns)) {
|
|
208
176
|
const sqlType = fieldTypeToSQLite(def.type);
|
|
209
177
|
const parts = [`"${colName}" ${sqlType}`];
|
|
210
|
-
|
|
211
178
|
if (def.primaryKey) parts.push("PRIMARY KEY");
|
|
212
179
|
if (def.autoIncrement) parts.push("AUTOINCREMENT");
|
|
213
180
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
214
|
-
if (def.default !== undefined && def.default !== "now") {
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
if (def.default === "now") {
|
|
218
|
-
parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
219
|
-
}
|
|
220
|
-
|
|
181
|
+
if (def.default !== undefined && def.default !== "now") parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
182
|
+
if (def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
221
183
|
colDefs.push(parts.join(" "));
|
|
222
184
|
}
|
|
223
|
-
|
|
224
|
-
const sql = `CREATE TABLE IF NOT EXISTS "${name}" (${colDefs.join(", ")})`;
|
|
225
|
-
this.db.exec(sql);
|
|
185
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs.join(", ")})`);
|
|
226
186
|
}
|
|
227
187
|
|
|
228
188
|
getTableColumns(name: string): Array<{ name: string; type: string }> {
|
|
229
|
-
return this.db.prepare(`PRAGMA table_info("${name}")`).all() as Array<{
|
|
230
|
-
name: string;
|
|
231
|
-
type: string;
|
|
232
|
-
}>;
|
|
189
|
+
return this.db.prepare(`PRAGMA table_info("${name}")`).all() as Array<{ name: string; type: string }>;
|
|
233
190
|
}
|
|
234
191
|
|
|
235
192
|
addColumn(table: string, colName: string, def: FieldDefinition): void {
|
|
236
193
|
const sqlType = fieldTypeToSQLite(def.type);
|
|
237
194
|
let sql = `ALTER TABLE "${table}" ADD COLUMN "${colName}" ${sqlType}`;
|
|
238
|
-
if (def.default !== undefined && def.default !== "now") {
|
|
239
|
-
|
|
240
|
-
} else if (def.default === "now") {
|
|
241
|
-
sql += " DEFAULT CURRENT_TIMESTAMP";
|
|
242
|
-
}
|
|
195
|
+
if (def.default !== undefined && def.default !== "now") sql += ` DEFAULT ${sqlDefault(def.default)}`;
|
|
196
|
+
else if (def.default === "now") sql += " DEFAULT CURRENT_TIMESTAMP";
|
|
243
197
|
this.db.exec(sql);
|
|
244
198
|
}
|
|
245
199
|
}
|
|
246
200
|
|
|
247
201
|
function fieldTypeToSQLite(type: string): string {
|
|
248
202
|
switch (type) {
|
|
249
|
-
case "integer":
|
|
250
|
-
|
|
251
|
-
case "
|
|
252
|
-
case "
|
|
253
|
-
|
|
254
|
-
case "
|
|
255
|
-
return "INTEGER";
|
|
256
|
-
case "datetime":
|
|
257
|
-
return "TEXT";
|
|
258
|
-
case "text":
|
|
259
|
-
return "TEXT";
|
|
260
|
-
case "string":
|
|
261
|
-
default:
|
|
262
|
-
return "TEXT";
|
|
203
|
+
case "integer": return "INTEGER";
|
|
204
|
+
case "number": case "numeric": return "REAL";
|
|
205
|
+
case "boolean": return "INTEGER";
|
|
206
|
+
case "datetime": return "TEXT";
|
|
207
|
+
case "text": return "TEXT";
|
|
208
|
+
case "string": default: return "TEXT";
|
|
263
209
|
}
|
|
264
210
|
}
|
|
265
211
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getAdapter, getNamedAdapter } from "./database.js";
|
|
2
2
|
import { validate as validateFields } from "./validation.js";
|
|
3
|
+
import { QueryBuilder } from "./queryBuilder.js";
|
|
3
4
|
import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -90,6 +91,18 @@ export class BaseModel {
|
|
|
90
91
|
return reverse;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Create a fluent QueryBuilder pre-configured for this model's table and database.
|
|
96
|
+
*
|
|
97
|
+
* Usage:
|
|
98
|
+
* const results = User.query().where("active = ?", [1]).orderBy("name").get();
|
|
99
|
+
*
|
|
100
|
+
* @returns A QueryBuilder instance bound to this model's table and database.
|
|
101
|
+
*/
|
|
102
|
+
static query(): QueryBuilder {
|
|
103
|
+
return QueryBuilder.from(this.tableName, this.getDb());
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
/**
|
|
94
107
|
* Get the database adapter for this model.
|
|
95
108
|
*/
|
|
@@ -39,6 +39,7 @@ export { buildQuery, parseQueryString } from "./query.js";
|
|
|
39
39
|
export { validate } from "./validation.js";
|
|
40
40
|
export type { ValidationError } from "./validation.js";
|
|
41
41
|
export { BaseModel } from "./baseModel.js";
|
|
42
|
+
export { QueryBuilder } from "./queryBuilder.js";
|
|
42
43
|
export { SQLTranslator, QueryCache } from "./sqlTranslation.js";
|
|
43
44
|
export { CachedDatabaseAdapter } from "./cachedDatabase.js";
|
|
44
45
|
export { FakeData } from "./fakeData.js";
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QueryBuilder — Fluent SQL query builder for Tina4 Node.js.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* // Standalone
|
|
6
|
+
* const result = QueryBuilder.from("users", db)
|
|
7
|
+
* .select("id", "name")
|
|
8
|
+
* .where("active = ?", [1])
|
|
9
|
+
* .orderBy("name ASC")
|
|
10
|
+
* .limit(10)
|
|
11
|
+
* .get();
|
|
12
|
+
*
|
|
13
|
+
* // From ORM model
|
|
14
|
+
* const result = User.query()
|
|
15
|
+
* .where("age > ?", [18])
|
|
16
|
+
* .orderBy("name")
|
|
17
|
+
* .get();
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DatabaseAdapter } from "./types.js";
|
|
21
|
+
import { getAdapter } from "./database.js";
|
|
22
|
+
|
|
23
|
+
export class QueryBuilder {
|
|
24
|
+
private table: string;
|
|
25
|
+
private db: DatabaseAdapter | undefined;
|
|
26
|
+
private columns: string[] = ["*"];
|
|
27
|
+
private wheres: [string, string][] = [];
|
|
28
|
+
private params: unknown[] = [];
|
|
29
|
+
private joinClauses: string[] = [];
|
|
30
|
+
private groupByCols: string[] = [];
|
|
31
|
+
private havings: string[] = [];
|
|
32
|
+
private havingParams: unknown[] = [];
|
|
33
|
+
private orderByCols: string[] = [];
|
|
34
|
+
private limitVal: number | undefined;
|
|
35
|
+
private offsetVal: number | undefined;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Private constructor — use static factory methods.
|
|
39
|
+
*/
|
|
40
|
+
private constructor(table: string, db?: DatabaseAdapter) {
|
|
41
|
+
this.table = table;
|
|
42
|
+
this.db = db;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a QueryBuilder for a table.
|
|
47
|
+
*
|
|
48
|
+
* @param tableName - Table name.
|
|
49
|
+
* @param db - Optional database adapter.
|
|
50
|
+
* @returns A new QueryBuilder instance.
|
|
51
|
+
*/
|
|
52
|
+
static from(tableName: string, db?: DatabaseAdapter): QueryBuilder {
|
|
53
|
+
return new QueryBuilder(tableName, db);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Set the columns to select.
|
|
58
|
+
*
|
|
59
|
+
* @param cols - Column names.
|
|
60
|
+
* @returns this for chaining.
|
|
61
|
+
*/
|
|
62
|
+
select(...cols: string[]): QueryBuilder {
|
|
63
|
+
if (cols.length > 0) {
|
|
64
|
+
this.columns = cols;
|
|
65
|
+
}
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Add a WHERE condition (AND).
|
|
71
|
+
*
|
|
72
|
+
* @param condition - SQL condition with ? placeholders.
|
|
73
|
+
* @param params - Parameter values.
|
|
74
|
+
* @returns this for chaining.
|
|
75
|
+
*/
|
|
76
|
+
where(condition: string, params: unknown[] = []): QueryBuilder {
|
|
77
|
+
this.wheres.push(["AND", condition]);
|
|
78
|
+
this.params.push(...params);
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Add a WHERE condition (OR).
|
|
84
|
+
*
|
|
85
|
+
* @param condition - SQL condition with ? placeholders.
|
|
86
|
+
* @param params - Parameter values.
|
|
87
|
+
* @returns this for chaining.
|
|
88
|
+
*/
|
|
89
|
+
orWhere(condition: string, params: unknown[] = []): QueryBuilder {
|
|
90
|
+
this.wheres.push(["OR", condition]);
|
|
91
|
+
this.params.push(...params);
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Add an INNER JOIN.
|
|
97
|
+
*
|
|
98
|
+
* @param table - Table to join.
|
|
99
|
+
* @param onClause - Join condition.
|
|
100
|
+
* @returns this for chaining.
|
|
101
|
+
*/
|
|
102
|
+
join(table: string, onClause: string): QueryBuilder {
|
|
103
|
+
this.joinClauses.push(`INNER JOIN ${table} ON ${onClause}`);
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Add a LEFT JOIN.
|
|
109
|
+
*
|
|
110
|
+
* @param table - Table to join.
|
|
111
|
+
* @param onClause - Join condition.
|
|
112
|
+
* @returns this for chaining.
|
|
113
|
+
*/
|
|
114
|
+
leftJoin(table: string, onClause: string): QueryBuilder {
|
|
115
|
+
this.joinClauses.push(`LEFT JOIN ${table} ON ${onClause}`);
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Add a GROUP BY column.
|
|
121
|
+
*
|
|
122
|
+
* @param column - Column name.
|
|
123
|
+
* @returns this for chaining.
|
|
124
|
+
*/
|
|
125
|
+
groupBy(column: string): QueryBuilder {
|
|
126
|
+
this.groupByCols.push(column);
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Add a HAVING clause.
|
|
132
|
+
*
|
|
133
|
+
* @param expression - HAVING expression with ? placeholders.
|
|
134
|
+
* @param params - Parameter values.
|
|
135
|
+
* @returns this for chaining.
|
|
136
|
+
*/
|
|
137
|
+
having(expression: string, params: unknown[] = []): QueryBuilder {
|
|
138
|
+
this.havings.push(expression);
|
|
139
|
+
this.havingParams.push(...params);
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Add an ORDER BY clause.
|
|
145
|
+
*
|
|
146
|
+
* @param expression - Column and direction (e.g. "name ASC").
|
|
147
|
+
* @returns this for chaining.
|
|
148
|
+
*/
|
|
149
|
+
orderBy(expression: string): QueryBuilder {
|
|
150
|
+
this.orderByCols.push(expression);
|
|
151
|
+
return this;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Set LIMIT and optional OFFSET.
|
|
156
|
+
*
|
|
157
|
+
* @param count - Maximum rows to return.
|
|
158
|
+
* @param offset - Number of rows to skip.
|
|
159
|
+
* @returns this for chaining.
|
|
160
|
+
*/
|
|
161
|
+
limit(count: number, offset?: number): QueryBuilder {
|
|
162
|
+
this.limitVal = count;
|
|
163
|
+
if (offset !== undefined) {
|
|
164
|
+
this.offsetVal = offset;
|
|
165
|
+
}
|
|
166
|
+
return this;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build and return the SQL string without executing.
|
|
171
|
+
*
|
|
172
|
+
* @returns The constructed SQL query.
|
|
173
|
+
*/
|
|
174
|
+
toSql(): string {
|
|
175
|
+
let sql = `SELECT ${this.columns.join(", ")} FROM ${this.table}`;
|
|
176
|
+
|
|
177
|
+
if (this.joinClauses.length > 0) {
|
|
178
|
+
sql += " " + this.joinClauses.join(" ");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (this.wheres.length > 0) {
|
|
182
|
+
sql += " WHERE " + this.buildWhere();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (this.groupByCols.length > 0) {
|
|
186
|
+
sql += " GROUP BY " + this.groupByCols.join(", ");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (this.havings.length > 0) {
|
|
190
|
+
sql += " HAVING " + this.havings.join(" AND ");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (this.orderByCols.length > 0) {
|
|
194
|
+
sql += " ORDER BY " + this.orderByCols.join(", ");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return sql;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Execute the query and return all matching rows.
|
|
202
|
+
*
|
|
203
|
+
* @returns Array of row objects.
|
|
204
|
+
*/
|
|
205
|
+
get<T = Record<string, unknown>>(): T[] {
|
|
206
|
+
this.ensureDb();
|
|
207
|
+
const sql = this.toSql();
|
|
208
|
+
const allParams = [...this.params, ...this.havingParams];
|
|
209
|
+
|
|
210
|
+
return this.db!.fetch<T>(
|
|
211
|
+
sql,
|
|
212
|
+
allParams.length > 0 ? allParams : undefined,
|
|
213
|
+
this.limitVal,
|
|
214
|
+
this.offsetVal,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Execute the query and return a single row.
|
|
220
|
+
*
|
|
221
|
+
* @returns A single row object, or null.
|
|
222
|
+
*/
|
|
223
|
+
first<T = Record<string, unknown>>(): T | null {
|
|
224
|
+
this.ensureDb();
|
|
225
|
+
const sql = this.toSql();
|
|
226
|
+
const allParams = [...this.params, ...this.havingParams];
|
|
227
|
+
|
|
228
|
+
return this.db!.fetchOne<T>(
|
|
229
|
+
sql,
|
|
230
|
+
allParams.length > 0 ? allParams : undefined,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Execute the query and return the row count.
|
|
236
|
+
*
|
|
237
|
+
* @returns Number of matching rows.
|
|
238
|
+
*/
|
|
239
|
+
count(): number {
|
|
240
|
+
this.ensureDb();
|
|
241
|
+
|
|
242
|
+
// Build a count query by replacing columns
|
|
243
|
+
const original = this.columns;
|
|
244
|
+
this.columns = ["COUNT(*) as cnt"];
|
|
245
|
+
const sql = this.toSql();
|
|
246
|
+
this.columns = original;
|
|
247
|
+
|
|
248
|
+
const allParams = [...this.params, ...this.havingParams];
|
|
249
|
+
|
|
250
|
+
const row = this.db!.fetchOne<Record<string, unknown>>(
|
|
251
|
+
sql,
|
|
252
|
+
allParams.length > 0 ? allParams : undefined,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (!row) return 0;
|
|
256
|
+
|
|
257
|
+
// Handle case-insensitive column names
|
|
258
|
+
const cnt = row["cnt"] ?? row["CNT"] ?? 0;
|
|
259
|
+
return Number(cnt);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Check whether any matching rows exist.
|
|
264
|
+
*
|
|
265
|
+
* @returns True if at least one row matches.
|
|
266
|
+
*/
|
|
267
|
+
exists(): boolean {
|
|
268
|
+
return this.count() > 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Build the WHERE clause from accumulated conditions.
|
|
273
|
+
*/
|
|
274
|
+
private buildWhere(): string {
|
|
275
|
+
const parts: string[] = [];
|
|
276
|
+
for (let i = 0; i < this.wheres.length; i++) {
|
|
277
|
+
const [connector, condition] = this.wheres[i];
|
|
278
|
+
if (i === 0) {
|
|
279
|
+
parts.push(condition);
|
|
280
|
+
} else {
|
|
281
|
+
parts.push(`${connector} ${condition}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return parts.join(" ");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Ensure a database adapter is available.
|
|
289
|
+
*/
|
|
290
|
+
private ensureDb(): void {
|
|
291
|
+
if (!this.db) {
|
|
292
|
+
try {
|
|
293
|
+
this.db = getAdapter();
|
|
294
|
+
} catch {
|
|
295
|
+
throw new Error("QueryBuilder: No database adapter provided.");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|