@apisr/drizzle-model 2.0.3 → 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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Change log
2
2
 
3
+ ## 2.0.4 | 02-03-2026
4
+ - Fix custom methods execution error
5
+ - Add custom methods tests
6
+
3
7
  ## 2.0.3 | 02-03-2026
4
8
  - I forgot to rebuild package in `2.0.2 to 2.0.0`, damn
5
9
 
@@ -111,8 +111,8 @@ var ModelRuntime = class ModelRuntime {
111
111
  ...this.config.options,
112
112
  ...nextOptions,
113
113
  methods: {
114
- ...nextOptions.methods ?? {},
115
- ...this.config.options.methods ?? {}
114
+ ...this.config.options.methods ?? {},
115
+ ...nextOptions.methods ?? {}
116
116
  },
117
117
  format: nextOptions.format ?? this.config.options.format
118
118
  }
@@ -1,36 +1,62 @@
1
1
  import { ModelRuntime } from "../core/runtime.mjs";
2
2
 
3
3
  //#region src/model/builder.ts
4
+ /**
5
+ * Wraps a {@link ModelRuntime} instance into a plain target object that:
6
+ *
7
+ * 1. Exposes all runtime prototype methods, intercepting any that return a
8
+ * `ModelRuntime` so the result is re-wrapped (preserving custom methods).
9
+ * 2. Defines the `$model`, `$modelName`, and `$format` identity getters.
10
+ * 3. Attaches user-defined custom methods from `options.methods` via
11
+ * `runtime.attachMethods`, binding each to the new `target`.
12
+ *
13
+ * This must be called every time a new `ModelRuntime` is produced (e.g. by
14
+ * `.where()`, `.extend()`, `.db()`) so that custom methods are never lost.
15
+ */
16
+ function buildTarget(runtime) {
17
+ const target = {};
18
+ for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(runtime))) {
19
+ if (key === "constructor") continue;
20
+ const value = runtime[key];
21
+ if (typeof value === "function") target[key] = (...args) => {
22
+ const result = value.apply(runtime, args);
23
+ if (result instanceof ModelRuntime) return buildTarget(result);
24
+ return result;
25
+ };
26
+ }
27
+ Object.defineProperty(target, "$model", {
28
+ get: () => runtime.$model,
29
+ enumerable: true
30
+ });
31
+ Object.defineProperty(target, "$modelName", {
32
+ get: () => runtime.$modelName,
33
+ enumerable: true
34
+ });
35
+ Object.defineProperty(target, "$format", {
36
+ get: () => runtime.$format,
37
+ enumerable: true
38
+ });
39
+ Object.defineProperty(target, "$where", {
40
+ get: () => runtime.$where,
41
+ enumerable: true
42
+ });
43
+ Object.defineProperty(target, "$tableName", {
44
+ get: () => runtime.$tableName,
45
+ enumerable: true
46
+ });
47
+ runtime.attachMethods(target);
48
+ return target;
49
+ }
4
50
  function modelBuilder({ db, relations, schema, dialect }) {
5
51
  return (table, options) => {
6
- const runtime = new ModelRuntime({
52
+ return buildTarget(new ModelRuntime({
7
53
  db,
8
54
  relations,
9
55
  schema,
10
56
  tableName: table,
11
57
  dialect,
12
58
  options: options ?? {}
13
- });
14
- const target = {};
15
- for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(runtime))) {
16
- if (key === "constructor") continue;
17
- const value = runtime[key];
18
- if (typeof value === "function") target[key] = value.bind(runtime);
19
- }
20
- Object.defineProperty(target, "$model", {
21
- get: () => runtime.$model,
22
- enumerable: true
23
- });
24
- Object.defineProperty(target, "$modelName", {
25
- get: () => runtime.$modelName,
26
- enumerable: true
27
- });
28
- Object.defineProperty(target, "$format", {
29
- get: () => runtime.$format,
30
- enumerable: true
31
- });
32
- runtime.attachMethods(target);
33
- return target;
59
+ }));
34
60
  };
35
61
  }
36
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apisr/drizzle-model",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "private": false,
5
5
  "scripts": {
6
6
  "lint": "eslint . --max-warnings 0",
@@ -177,8 +177,8 @@ export class ModelRuntime {
177
177
  ...this.config.options,
178
178
  ...nextOptions,
179
179
  methods: {
180
- ...(nextOptions.methods ?? {}),
181
180
  ...(this.config.options.methods ?? {}),
181
+ ...(nextOptions.methods ?? {}),
182
182
  },
183
183
  format: nextOptions.format ?? this.config.options.format,
184
184
  } as ModelOptions<never, never, never, never>,
@@ -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
- const target: Record<string, unknown> = {};
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
+ });