@apisr/drizzle-model 2.0.2 → 2.0.4
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/CHANGELOG.md +8 -1
- package/README.md +280 -37
- package/ROADMAP.md +3 -0
- package/dist/core/dialect.mjs +56 -0
- package/dist/core/query/joins.mjs +334 -0
- package/dist/core/query/projection.mjs +78 -0
- package/dist/core/query/where.mjs +265 -0
- package/dist/core/result.mjs +215 -0
- package/dist/core/runtime.mjs +393 -0
- package/dist/core/transform.mjs +83 -0
- package/dist/index.d.mts +4 -4
- package/dist/model/builder.mjs +49 -3
- package/dist/model/config.d.mts +6 -6
- package/dist/model/format.d.mts +6 -2
- package/dist/model/index.d.mts +2 -2
- package/dist/model/methods/exclude.d.mts +1 -1
- package/dist/model/methods/return.d.mts +2 -2
- package/dist/model/methods/select.d.mts +1 -1
- package/dist/model/model.d.mts +10 -12
- package/dist/model/query/error.d.mts +4 -0
- package/dist/model/query/operations.d.mts +89 -39
- package/dist/model/query/operations.mjs +12 -0
- package/dist/model/result.d.mts +26 -10
- package/dist/types.d.mts +16 -1
- package/package.json +1 -1
- package/src/core/runtime.ts +1 -1
- package/src/model/builder.ts +73 -30
- package/tests/base/custom-methods.test.ts +335 -0
- package/tests/snippets/x-2.ts +2 -0
- package/dist/model/core/joins.mjs +0 -184
- package/dist/model/core/projection.mjs +0 -28
- package/dist/model/core/runtime.mjs +0 -198
- package/dist/model/core/thenable.mjs +0 -64
- package/dist/model/core/transform.mjs +0 -39
- package/dist/model/core/where.mjs +0 -130
- package/dist/model/core/with.mjs +0 -19
package/src/model/builder.ts
CHANGED
|
@@ -5,6 +5,78 @@ import type { ModelDialect } from "./dialect.ts";
|
|
|
5
5
|
import type { Model } from "./model.ts";
|
|
6
6
|
import type { ModelOptions } from "./options.ts";
|
|
7
7
|
|
|
8
|
+
type AnyRecord = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wraps a {@link ModelRuntime} instance into a plain target object that:
|
|
12
|
+
*
|
|
13
|
+
* 1. Exposes all runtime prototype methods, intercepting any that return a
|
|
14
|
+
* `ModelRuntime` so the result is re-wrapped (preserving custom methods).
|
|
15
|
+
* 2. Defines the `$model`, `$modelName`, and `$format` identity getters.
|
|
16
|
+
* 3. Attaches user-defined custom methods from `options.methods` via
|
|
17
|
+
* `runtime.attachMethods`, binding each to the new `target`.
|
|
18
|
+
*
|
|
19
|
+
* This must be called every time a new `ModelRuntime` is produced (e.g. by
|
|
20
|
+
* `.where()`, `.extend()`, `.db()`) so that custom methods are never lost.
|
|
21
|
+
*/
|
|
22
|
+
function buildTarget(runtime: ModelRuntime): AnyRecord {
|
|
23
|
+
const target: AnyRecord = {};
|
|
24
|
+
|
|
25
|
+
for (const key of Object.getOwnPropertyNames(
|
|
26
|
+
Object.getPrototypeOf(runtime)
|
|
27
|
+
)) {
|
|
28
|
+
if (key === "constructor") {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const value = (runtime as unknown as AnyRecord)[key];
|
|
33
|
+
|
|
34
|
+
if (typeof value === "function") {
|
|
35
|
+
target[key] = (...args: unknown[]) => {
|
|
36
|
+
const result = (value as (...args: unknown[]) => unknown).apply(
|
|
37
|
+
runtime,
|
|
38
|
+
args
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Lifecycle methods (where, extend, db) return a new ModelRuntime.
|
|
42
|
+
// Re-wrap it so custom methods are re-attached on the fresh target.
|
|
43
|
+
if (result instanceof ModelRuntime) {
|
|
44
|
+
return buildTarget(result);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Object.defineProperty(target, "$model", {
|
|
53
|
+
get: () => runtime.$model,
|
|
54
|
+
enumerable: true,
|
|
55
|
+
});
|
|
56
|
+
Object.defineProperty(target, "$modelName", {
|
|
57
|
+
get: () => runtime.$modelName,
|
|
58
|
+
enumerable: true,
|
|
59
|
+
});
|
|
60
|
+
Object.defineProperty(target, "$format", {
|
|
61
|
+
get: () => runtime.$format,
|
|
62
|
+
enumerable: true,
|
|
63
|
+
});
|
|
64
|
+
Object.defineProperty(target, "$where", {
|
|
65
|
+
get: () => runtime.$where,
|
|
66
|
+
enumerable: true,
|
|
67
|
+
});
|
|
68
|
+
Object.defineProperty(target, "$tableName", {
|
|
69
|
+
get: () => runtime.$tableName,
|
|
70
|
+
enumerable: true,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Attach user-defined methods, binding each to this target so that `this`
|
|
74
|
+
// inside a custom method refers to the model-like object (with all methods).
|
|
75
|
+
runtime.attachMethods(target);
|
|
76
|
+
|
|
77
|
+
return target;
|
|
78
|
+
}
|
|
79
|
+
|
|
8
80
|
export function modelBuilder<
|
|
9
81
|
TFullSchema extends Record<string, unknown> = Record<string, never>,
|
|
10
82
|
TRelations extends AnyRelations = EmptyRelations,
|
|
@@ -44,36 +116,7 @@ export function modelBuilder<
|
|
|
44
116
|
options: (options ?? {}) as any,
|
|
45
117
|
});
|
|
46
118
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
for (const key of Object.getOwnPropertyNames(
|
|
50
|
-
Object.getPrototypeOf(runtime)
|
|
51
|
-
)) {
|
|
52
|
-
if (key === "constructor") {
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
const value = (runtime as unknown as Record<string, unknown>)[key];
|
|
56
|
-
if (typeof value === "function") {
|
|
57
|
-
target[key] = value.bind(runtime);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
Object.defineProperty(target, "$model", {
|
|
62
|
-
get: () => runtime.$model,
|
|
63
|
-
enumerable: true,
|
|
64
|
-
});
|
|
65
|
-
Object.defineProperty(target, "$modelName", {
|
|
66
|
-
get: () => runtime.$modelName,
|
|
67
|
-
enumerable: true,
|
|
68
|
-
});
|
|
69
|
-
Object.defineProperty(target, "$format", {
|
|
70
|
-
get: () => runtime.$format,
|
|
71
|
-
enumerable: true,
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
runtime.attachMethods(target);
|
|
75
|
-
|
|
76
|
-
return target as unknown as Model<
|
|
119
|
+
return buildTarget(runtime) as unknown as Model<
|
|
77
120
|
ModelConfig<TRelations, TRelations[TTableName], TDialect, TOptions>
|
|
78
121
|
>;
|
|
79
122
|
};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { model } from "tests/base";
|
|
3
|
+
import { db } from "tests/db";
|
|
4
|
+
import { esc } from "@/model";
|
|
5
|
+
|
|
6
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function uid(): string {
|
|
9
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ─── Models ───────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A user model that defines several custom methods.
|
|
16
|
+
* These must remain accessible after every lifecycle call
|
|
17
|
+
* (.where, .extend, .db).
|
|
18
|
+
*/
|
|
19
|
+
const userModel = model("user", {});
|
|
20
|
+
|
|
21
|
+
const serviceQueries = userModel.extend({
|
|
22
|
+
methods: {
|
|
23
|
+
findByName(name: string) {
|
|
24
|
+
return serviceQueries.where({ name: esc(name) }).findFirst();
|
|
25
|
+
},
|
|
26
|
+
findAllVerified() {
|
|
27
|
+
return serviceQueries.where({ isVerified: esc(true) }).findMany();
|
|
28
|
+
},
|
|
29
|
+
findByMinAge(minAge: number) {
|
|
30
|
+
return serviceQueries.where({ age: { gte: esc(minAge) } }).findMany();
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const postsModel = model("userPosts", {});
|
|
36
|
+
|
|
37
|
+
const postQueries = postsModel.extend({
|
|
38
|
+
methods: {
|
|
39
|
+
findByTitle(title: string) {
|
|
40
|
+
return postQueries.where({ title: esc(title) }).findFirst();
|
|
41
|
+
},
|
|
42
|
+
findFeatured() {
|
|
43
|
+
return postQueries.where({ featured: esc(true) }).findMany();
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("custom methods", () => {
|
|
51
|
+
// -------------------------------------------------------------------------
|
|
52
|
+
// Presence — methods must be callable on the initial model object
|
|
53
|
+
// -------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
describe("presence on initial model", () => {
|
|
56
|
+
test("all declared methods are functions on the model", () => {
|
|
57
|
+
expect(typeof serviceQueries.findByName).toBe("function");
|
|
58
|
+
expect(typeof serviceQueries.findAllVerified).toBe("function");
|
|
59
|
+
expect(typeof serviceQueries.findByMinAge).toBe("function");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("multiple models keep their own method sets independent", () => {
|
|
63
|
+
expect(typeof serviceQueries.findByName).toBe("function");
|
|
64
|
+
expect(typeof postQueries.findByTitle).toBe("function");
|
|
65
|
+
// cross-model — methods must NOT bleed across models
|
|
66
|
+
expect(
|
|
67
|
+
(serviceQueries as { findByTitle?: unknown }).findByTitle
|
|
68
|
+
).toBeUndefined();
|
|
69
|
+
expect(
|
|
70
|
+
(postQueries as { findByName?: unknown }).findByName
|
|
71
|
+
).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// -------------------------------------------------------------------------
|
|
76
|
+
// Survival through .where() — THE PRIMARY BUG
|
|
77
|
+
// Custom methods must survive every `.where()` call.
|
|
78
|
+
// -------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe("survival after .where()", () => {
|
|
81
|
+
test("custom methods are present on the result of .where()", () => {
|
|
82
|
+
const scoped = serviceQueries.where({ age: { gte: esc(0) } });
|
|
83
|
+
expect(typeof scoped.findByName).toBe("function");
|
|
84
|
+
expect(typeof scoped.findAllVerified).toBe("function");
|
|
85
|
+
expect(typeof scoped.findByMinAge).toBe("function");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("custom methods are present after chained .where() calls", () => {
|
|
89
|
+
const scoped = serviceQueries
|
|
90
|
+
.where({ age: { gte: esc(0) } })
|
|
91
|
+
.where({ isVerified: esc(false) });
|
|
92
|
+
expect(typeof scoped.findByName).toBe("function");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("base methods are still present after .where()", () => {
|
|
96
|
+
const scoped = serviceQueries.where({ age: { gte: esc(0) } });
|
|
97
|
+
expect(typeof scoped.findFirst).toBe("function");
|
|
98
|
+
expect(typeof scoped.findMany).toBe("function");
|
|
99
|
+
expect(typeof scoped.insert).toBe("function");
|
|
100
|
+
expect(typeof scoped.update).toBe("function");
|
|
101
|
+
expect(typeof scoped.delete).toBe("function");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// -------------------------------------------------------------------------
|
|
106
|
+
// Survival through .extend()
|
|
107
|
+
// -------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe("survival after .extend()", () => {
|
|
110
|
+
test("original custom methods survive .extend()", () => {
|
|
111
|
+
const extended = serviceQueries.extend({});
|
|
112
|
+
expect(typeof extended.findByName).toBe("function");
|
|
113
|
+
expect(typeof extended.findAllVerified).toBe("function");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("new methods added via .extend() are accessible", () => {
|
|
117
|
+
const extended = serviceQueries.extend({
|
|
118
|
+
methods: {
|
|
119
|
+
findAdults() {
|
|
120
|
+
return extended.where({ age: { gte: esc(18) } }).findMany();
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
expect(typeof extended.findAdults).toBe("function");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("parent custom methods survive on an extended model", () => {
|
|
128
|
+
const extended = serviceQueries.extend({
|
|
129
|
+
methods: {
|
|
130
|
+
findAdults() {
|
|
131
|
+
return extended.where({ age: { gte: esc(18) } }).findMany();
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
// Parent methods must still be present on the child
|
|
136
|
+
expect(typeof extended.findByName).toBe("function");
|
|
137
|
+
expect(typeof extended.findAllVerified).toBe("function");
|
|
138
|
+
// New method must be present too
|
|
139
|
+
expect(typeof extended.findAdults).toBe("function");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("child method overrides parent method of the same name", () => {
|
|
143
|
+
const SENTINEL = Symbol("override-sentinel");
|
|
144
|
+
const extended = serviceQueries.extend({
|
|
145
|
+
methods: {
|
|
146
|
+
findByName(_name: string) {
|
|
147
|
+
return SENTINEL;
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
// The extended model's findByName must shadow the parent's
|
|
152
|
+
expect(extended.findByName("ignored")).toBe(SENTINEL);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("custom methods survive .where() on an extended model", () => {
|
|
156
|
+
const extended = serviceQueries.extend({
|
|
157
|
+
methods: {
|
|
158
|
+
findAdults() {
|
|
159
|
+
return extended.where({ age: { gte: esc(18) } }).findMany();
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
const scoped = extended.where({ isVerified: esc(true) });
|
|
164
|
+
expect(typeof scoped.findByName).toBe("function");
|
|
165
|
+
expect(typeof scoped.findAdults).toBe("function");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// -------------------------------------------------------------------------
|
|
170
|
+
// Survival through .db()
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
describe("survival after .db()", () => {
|
|
174
|
+
test("custom methods are present after .db() rebind", () => {
|
|
175
|
+
const rebound = serviceQueries.db(db);
|
|
176
|
+
expect(typeof rebound.findByName).toBe("function");
|
|
177
|
+
expect(typeof rebound.findAllVerified).toBe("function");
|
|
178
|
+
expect(typeof rebound.findByMinAge).toBe("function");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("base methods are still present after .db() rebind", () => {
|
|
182
|
+
const rebound = serviceQueries.db(db);
|
|
183
|
+
expect(typeof rebound.findFirst).toBe("function");
|
|
184
|
+
expect(typeof rebound.findMany).toBe("function");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("custom methods survive .where() after .db() rebind", () => {
|
|
188
|
+
const rebound = serviceQueries.db(db).where({ age: { gte: esc(0) } });
|
|
189
|
+
expect(typeof rebound.findByName).toBe("function");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
// Functional correctness — custom methods actually return the right data
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
describe("functional correctness", () => {
|
|
198
|
+
test("findByName returns a single user with matching name", async () => {
|
|
199
|
+
const uniqueName = `TestUser-${uid()}`;
|
|
200
|
+
const uniqueEmail = `${uid()}@custom-methods.test`;
|
|
201
|
+
|
|
202
|
+
await serviceQueries.insert({
|
|
203
|
+
name: uniqueName,
|
|
204
|
+
email: uniqueEmail,
|
|
205
|
+
age: 25,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const result = await serviceQueries.findByName(uniqueName);
|
|
209
|
+
|
|
210
|
+
expect(result).toBeDefined();
|
|
211
|
+
expect(result?.name).toBe(uniqueName);
|
|
212
|
+
expect(typeof result?.id).toBe("number");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("findAllVerified returns only verified users", async () => {
|
|
216
|
+
const result = await serviceQueries.findAllVerified();
|
|
217
|
+
|
|
218
|
+
expect(Array.isArray(result)).toBe(true);
|
|
219
|
+
for (const user of result) {
|
|
220
|
+
expect(user.isVerified).toBe(true);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("findByMinAge returns users at or above the age threshold", async () => {
|
|
225
|
+
const result = await serviceQueries.findByMinAge(18);
|
|
226
|
+
|
|
227
|
+
expect(Array.isArray(result)).toBe(true);
|
|
228
|
+
for (const user of result) {
|
|
229
|
+
expect(user.age).toBeGreaterThanOrEqual(18);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("custom method result is consistent with equivalent base query", async () => {
|
|
234
|
+
const uniqueName = `Consistent-${uid()}`;
|
|
235
|
+
const uniqueEmail = `${uid()}@custom-methods.test`;
|
|
236
|
+
|
|
237
|
+
await serviceQueries.insert({
|
|
238
|
+
name: uniqueName,
|
|
239
|
+
email: uniqueEmail,
|
|
240
|
+
age: 30,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const viaCustom = await serviceQueries.findByName(uniqueName);
|
|
244
|
+
const viaBase = await serviceQueries
|
|
245
|
+
.where({ name: esc(uniqueName) })
|
|
246
|
+
.findFirst();
|
|
247
|
+
|
|
248
|
+
expect(viaCustom).toEqual(viaBase);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("posts custom method findByTitle returns correct post", async () => {
|
|
252
|
+
const uniqueTitle = `PostTitle-${uid()}`;
|
|
253
|
+
|
|
254
|
+
await postQueries.insert({
|
|
255
|
+
title: uniqueTitle,
|
|
256
|
+
featured: false,
|
|
257
|
+
userId: 1,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const result = await postQueries.findByTitle(uniqueTitle);
|
|
261
|
+
|
|
262
|
+
expect(result).toBeDefined();
|
|
263
|
+
expect(result?.title).toBe(uniqueTitle);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("posts custom method findFeatured returns only featured posts", async () => {
|
|
267
|
+
await postQueries.insert({
|
|
268
|
+
title: `Featured-${uid()}`,
|
|
269
|
+
featured: true,
|
|
270
|
+
userId: 1,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const result = await postQueries.findFeatured();
|
|
274
|
+
|
|
275
|
+
expect(Array.isArray(result)).toBe(true);
|
|
276
|
+
expect(result.length).toBeGreaterThan(0);
|
|
277
|
+
for (const post of result) {
|
|
278
|
+
expect(post.featured).toBe(true);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("custom method is still functional after .where() (the bug scenario)", async () => {
|
|
283
|
+
const uniqueName = `BugCheck-${uid()}`;
|
|
284
|
+
const uniqueEmail = `${uid()}@custom-methods.test`;
|
|
285
|
+
|
|
286
|
+
await serviceQueries.insert({
|
|
287
|
+
name: uniqueName,
|
|
288
|
+
email: uniqueEmail,
|
|
289
|
+
age: 99,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const scopedModel = serviceQueries.where({ age: { gte: esc(0) } });
|
|
293
|
+
const result = await scopedModel.findByName(uniqueName);
|
|
294
|
+
|
|
295
|
+
expect(result).toBeDefined();
|
|
296
|
+
expect(result?.name).toBe(uniqueName);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("custom method is still functional after .extend()", async () => {
|
|
300
|
+
const uniqueName = `ExtendBug-${uid()}`;
|
|
301
|
+
const uniqueEmail = `${uid()}@custom-methods.test`;
|
|
302
|
+
|
|
303
|
+
await serviceQueries.insert({
|
|
304
|
+
name: uniqueName,
|
|
305
|
+
email: uniqueEmail,
|
|
306
|
+
age: 22,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const extended = serviceQueries.extend({
|
|
310
|
+
format: (u) => ({ ...u, _extended: true }),
|
|
311
|
+
});
|
|
312
|
+
const result = await extended.findByName(uniqueName);
|
|
313
|
+
|
|
314
|
+
expect(result).toBeDefined();
|
|
315
|
+
expect(result?.name).toBe(uniqueName);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("custom method is still functional after .db()", async () => {
|
|
319
|
+
const uniqueName = `DbBug-${uid()}`;
|
|
320
|
+
const uniqueEmail = `${uid()}@custom-methods.test`;
|
|
321
|
+
|
|
322
|
+
await serviceQueries.insert({
|
|
323
|
+
name: uniqueName,
|
|
324
|
+
email: uniqueEmail,
|
|
325
|
+
age: 33,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const rebound = serviceQueries.db(db);
|
|
329
|
+
const result = await rebound.findByName(uniqueName);
|
|
330
|
+
|
|
331
|
+
expect(result).toBeDefined();
|
|
332
|
+
expect(result?.name).toBe(uniqueName);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
package/tests/snippets/x-2.ts
CHANGED
|
@@ -13,6 +13,7 @@ const model = modelBuilder({
|
|
|
13
13
|
// create model
|
|
14
14
|
const userModel = model("user", {});
|
|
15
15
|
|
|
16
|
+
// #1 syntax
|
|
16
17
|
await userModel
|
|
17
18
|
.where({
|
|
18
19
|
name: {
|
|
@@ -21,6 +22,7 @@ await userModel
|
|
|
21
22
|
})
|
|
22
23
|
.findFirst();
|
|
23
24
|
|
|
25
|
+
// #2 syntax
|
|
24
26
|
await userModel
|
|
25
27
|
.where({
|
|
26
28
|
name: esc.like("A%"),
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import { and, eq } from "drizzle-orm";
|
|
2
|
-
|
|
3
|
-
//#region src/model/core/joins.ts
|
|
4
|
-
function isDrizzleColumn(value) {
|
|
5
|
-
return !!value && typeof value === "object" && typeof value.getSQL === "function";
|
|
6
|
-
}
|
|
7
|
-
function getPrimaryKeyField(table) {
|
|
8
|
-
for (const [k, v] of Object.entries(table)) {
|
|
9
|
-
if (!isDrizzleColumn(v)) continue;
|
|
10
|
-
if (v.primary === true) return k;
|
|
11
|
-
if (v.config?.primaryKey === true) return k;
|
|
12
|
-
}
|
|
13
|
-
if ("id" in table) return "id";
|
|
14
|
-
return Object.keys(table).find((k) => isDrizzleColumn(table[k])) ?? "id";
|
|
15
|
-
}
|
|
16
|
-
function isAllNullRow(obj) {
|
|
17
|
-
if (!obj || typeof obj !== "object") return true;
|
|
18
|
-
for (const v of Object.values(obj)) if (v !== null && v !== void 0) return false;
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
async function aliasTable(table, aliasName, dialect) {
|
|
22
|
-
if (dialect === "PostgreSQL") {
|
|
23
|
-
const mod = await import("drizzle-orm/pg-core");
|
|
24
|
-
if (typeof mod.alias === "function") return mod.alias(table, aliasName);
|
|
25
|
-
}
|
|
26
|
-
if (dialect === "MySQL") {
|
|
27
|
-
const mod = await import("drizzle-orm/mysql-core");
|
|
28
|
-
if (typeof mod.alias === "function") return mod.alias(table, aliasName);
|
|
29
|
-
}
|
|
30
|
-
if (dialect === "SQLite") {
|
|
31
|
-
const mod = await import("drizzle-orm/sqlite-core");
|
|
32
|
-
if (typeof mod.alias === "function") return mod.alias(table, aliasName);
|
|
33
|
-
}
|
|
34
|
-
return table;
|
|
35
|
-
}
|
|
36
|
-
function buildJoinOn(node) {
|
|
37
|
-
const parts = node.sourceColumns.map((src, i) => {
|
|
38
|
-
const tgt = node.targetColumns[i];
|
|
39
|
-
const tgtKey = Object.entries(node.targetTable).find(([, v]) => v === tgt)?.[0];
|
|
40
|
-
return eq(tgtKey ? node.targetAliasTable[tgtKey] : tgt, src);
|
|
41
|
-
});
|
|
42
|
-
return parts.length === 1 ? parts[0] : and(...parts);
|
|
43
|
-
}
|
|
44
|
-
function buildSelectMapForTable(table) {
|
|
45
|
-
const out = {};
|
|
46
|
-
for (const [k, v] of Object.entries(table)) if (isDrizzleColumn(v)) out[k] = v;
|
|
47
|
-
return out;
|
|
48
|
-
}
|
|
49
|
-
async function executeWithJoins(args) {
|
|
50
|
-
const { db, schema, relations, baseTableName, baseTable, dialect, whereSql, withValue, limitOne } = args;
|
|
51
|
-
const usedAliasKeys = /* @__PURE__ */ new Set();
|
|
52
|
-
const buildNode = async (parent, currentTableName, currentTable, key, value, path) => {
|
|
53
|
-
const relMeta = relations[currentTableName]?.relations?.[key];
|
|
54
|
-
if (!relMeta) throw new Error(`Unknown relation '${key}' on table '${currentTableName}'.`);
|
|
55
|
-
const targetTableName = relMeta.targetTableName;
|
|
56
|
-
const targetTable = schema[targetTableName];
|
|
57
|
-
const aliasKeyBase = [...path, key].join("__");
|
|
58
|
-
let aliasKey = aliasKeyBase;
|
|
59
|
-
let idx = 1;
|
|
60
|
-
while (usedAliasKeys.has(aliasKey)) aliasKey = `${aliasKeyBase}_${idx++}`;
|
|
61
|
-
usedAliasKeys.add(aliasKey);
|
|
62
|
-
const needsAlias = targetTableName === currentTableName || usedAliasKeys.has(`table:${targetTableName}`);
|
|
63
|
-
usedAliasKeys.add(`table:${targetTableName}`);
|
|
64
|
-
const targetAliasTable = needsAlias ? await aliasTable(targetTable, aliasKey, dialect) : targetTable;
|
|
65
|
-
const node = {
|
|
66
|
-
path: [...path, key],
|
|
67
|
-
key,
|
|
68
|
-
relationType: relMeta.relationType,
|
|
69
|
-
sourceTableName: currentTableName,
|
|
70
|
-
targetTableName,
|
|
71
|
-
sourceTable: currentTable,
|
|
72
|
-
targetTable,
|
|
73
|
-
targetAliasTable,
|
|
74
|
-
aliasKey,
|
|
75
|
-
sourceColumns: relMeta.sourceColumns ?? [],
|
|
76
|
-
targetColumns: relMeta.targetColumns ?? [],
|
|
77
|
-
pkField: getPrimaryKeyField(targetAliasTable),
|
|
78
|
-
parent,
|
|
79
|
-
children: []
|
|
80
|
-
};
|
|
81
|
-
if (value && typeof value === "object" && value !== true) for (const [childKey, childVal] of Object.entries(value)) {
|
|
82
|
-
if (childVal !== true && (typeof childVal !== "object" || childVal == null)) continue;
|
|
83
|
-
const child = await buildNode(node, targetTableName, targetAliasTable, childKey, childVal, [...path, key]);
|
|
84
|
-
node.children.push(child);
|
|
85
|
-
}
|
|
86
|
-
return node;
|
|
87
|
-
};
|
|
88
|
-
const root = {
|
|
89
|
-
path: [],
|
|
90
|
-
key: "$root",
|
|
91
|
-
relationType: "one",
|
|
92
|
-
sourceTableName: baseTableName,
|
|
93
|
-
targetTableName: baseTableName,
|
|
94
|
-
sourceTable: baseTable,
|
|
95
|
-
targetTable: baseTable,
|
|
96
|
-
targetAliasTable: baseTable,
|
|
97
|
-
aliasKey: "$base",
|
|
98
|
-
sourceColumns: [],
|
|
99
|
-
targetColumns: [],
|
|
100
|
-
pkField: getPrimaryKeyField(baseTable),
|
|
101
|
-
children: []
|
|
102
|
-
};
|
|
103
|
-
for (const [key, value] of Object.entries(withValue)) {
|
|
104
|
-
if (value !== true && (typeof value !== "object" || value == null)) continue;
|
|
105
|
-
const child = await buildNode(void 0, baseTableName, baseTable, key, value, []);
|
|
106
|
-
root.children.push(child);
|
|
107
|
-
}
|
|
108
|
-
const nodes = [];
|
|
109
|
-
const walk = (n) => {
|
|
110
|
-
for (const c of n.children) {
|
|
111
|
-
nodes.push(c);
|
|
112
|
-
walk(c);
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
walk(root);
|
|
116
|
-
const selectMap = { base: buildSelectMapForTable(baseTable) };
|
|
117
|
-
for (const n of nodes) selectMap[n.aliasKey] = buildSelectMapForTable(n.targetAliasTable);
|
|
118
|
-
let q = db.select(selectMap).from(baseTable);
|
|
119
|
-
if (whereSql) q = q.where(whereSql);
|
|
120
|
-
for (const n of nodes) {
|
|
121
|
-
const on = buildJoinOn(n);
|
|
122
|
-
q = q.leftJoin(n.targetAliasTable, on);
|
|
123
|
-
}
|
|
124
|
-
if (limitOne) q = q.limit(1);
|
|
125
|
-
const rows = await q;
|
|
126
|
-
const basePk = root.pkField;
|
|
127
|
-
const baseMap = /* @__PURE__ */ new Map();
|
|
128
|
-
const ensureManyContainer = (obj, key) => {
|
|
129
|
-
if (!Array.isArray(obj[key])) obj[key] = [];
|
|
130
|
-
};
|
|
131
|
-
const ensureOneContainer = (obj, key) => {
|
|
132
|
-
if (!(key in obj)) obj[key] = null;
|
|
133
|
-
};
|
|
134
|
-
const manyIndexByPath = /* @__PURE__ */ new Map();
|
|
135
|
-
for (const row of rows) {
|
|
136
|
-
const baseRow = row.base;
|
|
137
|
-
const baseId = baseRow[basePk];
|
|
138
|
-
if (baseId === void 0) continue;
|
|
139
|
-
const baseObj = (() => {
|
|
140
|
-
const existing = baseMap.get(baseId);
|
|
141
|
-
if (existing) return existing;
|
|
142
|
-
const created = { ...baseRow };
|
|
143
|
-
baseMap.set(baseId, created);
|
|
144
|
-
return created;
|
|
145
|
-
})();
|
|
146
|
-
for (const n of nodes) {
|
|
147
|
-
const data = row[n.aliasKey];
|
|
148
|
-
const relPath = n.path.join(".");
|
|
149
|
-
const parentPath = n.parent ? n.parent.path.join(".") : "";
|
|
150
|
-
let parentObj = baseObj;
|
|
151
|
-
if (parentPath) {
|
|
152
|
-
const parentIndex = manyIndexByPath.get(parentPath);
|
|
153
|
-
if (parentIndex && parentIndex.size) parentObj = Array.from(parentIndex.values()).at(-1);
|
|
154
|
-
else {
|
|
155
|
-
const parentKey = n.parent?.key;
|
|
156
|
-
parentObj = parentKey ? baseObj[parentKey] ?? baseObj : baseObj;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if (isAllNullRow(data)) {
|
|
160
|
-
if (n.relationType === "one") ensureOneContainer(parentObj, n.key);
|
|
161
|
-
else ensureManyContainer(parentObj, n.key);
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
const pk = data[n.pkField];
|
|
165
|
-
if (n.relationType === "one") parentObj[n.key] = { ...data };
|
|
166
|
-
else {
|
|
167
|
-
ensureManyContainer(parentObj, n.key);
|
|
168
|
-
const indexKey = relPath;
|
|
169
|
-
if (!manyIndexByPath.has(indexKey)) manyIndexByPath.set(indexKey, /* @__PURE__ */ new Map());
|
|
170
|
-
const idxMap = manyIndexByPath.get(indexKey);
|
|
171
|
-
if (!idxMap.has(pk)) {
|
|
172
|
-
const obj = { ...data };
|
|
173
|
-
idxMap.set(pk, obj);
|
|
174
|
-
parentObj[n.key].push(obj);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
const out = Array.from(baseMap.values());
|
|
180
|
-
return limitOne ? out[0] : out;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
//#endregion
|
|
184
|
-
export { executeWithJoins };
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
//#region src/model/core/projection.ts
|
|
2
|
-
function isDrizzleColumn(value) {
|
|
3
|
-
return !!value && typeof value === "object" && typeof value.getSQL === "function";
|
|
4
|
-
}
|
|
5
|
-
function getTableColumnsMap(table) {
|
|
6
|
-
const out = {};
|
|
7
|
-
for (const [key, value] of Object.entries(table)) if (isDrizzleColumn(value)) out[key] = value;
|
|
8
|
-
return out;
|
|
9
|
-
}
|
|
10
|
-
function buildSelectProjection(table, select, exclude) {
|
|
11
|
-
const all = getTableColumnsMap(table);
|
|
12
|
-
if (select && typeof select === "object") {
|
|
13
|
-
const picked = {};
|
|
14
|
-
for (const [key, value] of Object.entries(select)) if (value === true && key in all) picked[key] = all[key];
|
|
15
|
-
if (Object.keys(picked).length) return { selectMap: picked };
|
|
16
|
-
return { selectMap: all };
|
|
17
|
-
}
|
|
18
|
-
if (exclude && typeof exclude === "object") {
|
|
19
|
-
const omitted = { ...all };
|
|
20
|
-
for (const [key, value] of Object.entries(exclude)) if (value === true) delete omitted[key];
|
|
21
|
-
if (Object.keys(omitted).length) return { selectMap: omitted };
|
|
22
|
-
return { selectMap: all };
|
|
23
|
-
}
|
|
24
|
-
return { selectMap: all };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
//#endregion
|
|
28
|
-
export { buildSelectProjection };
|