@fragno-dev/db 0.1.12 → 0.1.13
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/.turbo/turbo-build.log +27 -27
- package/CHANGELOG.md +6 -0
- package/dist/adapters/drizzle/drizzle-adapter.d.ts.map +1 -1
- package/dist/adapters/drizzle/drizzle-adapter.js +1 -1
- package/dist/adapters/drizzle/drizzle-query.d.ts.map +1 -1
- package/dist/adapters/drizzle/drizzle-query.js +4 -0
- package/dist/adapters/drizzle/drizzle-query.js.map +1 -1
- package/dist/adapters/drizzle/drizzle-uow-compiler.js +2 -1
- package/dist/adapters/drizzle/drizzle-uow-compiler.js.map +1 -1
- package/dist/adapters/drizzle/drizzle-uow-decoder.js +25 -1
- package/dist/adapters/drizzle/drizzle-uow-decoder.js.map +1 -1
- package/dist/adapters/drizzle/generate.js +1 -1
- package/dist/adapters/kysely/kysely-adapter.d.ts +1 -1
- package/dist/adapters/kysely/kysely-query.d.ts.map +1 -1
- package/dist/adapters/kysely/kysely-query.js +29 -1
- package/dist/adapters/kysely/kysely-query.js.map +1 -1
- package/dist/adapters/kysely/kysely-uow-compiler.js +2 -1
- package/dist/adapters/kysely/kysely-uow-compiler.js.map +1 -1
- package/dist/adapters/kysely/migration/execute-base.js +1 -1
- package/dist/mod.d.ts +2 -1
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +2 -1
- package/dist/mod.js.map +1 -1
- package/dist/query/cursor.d.ts +67 -32
- package/dist/query/cursor.d.ts.map +1 -1
- package/dist/query/cursor.js +84 -31
- package/dist/query/cursor.js.map +1 -1
- package/dist/query/query.d.ts +5 -0
- package/dist/query/query.d.ts.map +1 -1
- package/dist/query/unit-of-work.d.ts +14 -4
- package/dist/query/unit-of-work.d.ts.map +1 -1
- package/dist/query/unit-of-work.js +52 -9
- package/dist/query/unit-of-work.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +72 -5
- package/src/adapters/drizzle/drizzle-adapter-sqlite.test.ts +6 -4
- package/src/adapters/drizzle/drizzle-query.ts +9 -0
- package/src/adapters/drizzle/drizzle-uow-compiler.test.ts +19 -3
- package/src/adapters/drizzle/drizzle-uow-compiler.ts +3 -2
- package/src/adapters/drizzle/drizzle-uow-decoder.ts +40 -1
- package/src/adapters/kysely/kysely-adapter-pglite.test.ts +102 -4
- package/src/adapters/kysely/kysely-query.ts +50 -1
- package/src/adapters/kysely/kysely-uow-compiler.test.ts +19 -3
- package/src/adapters/kysely/kysely-uow-compiler.ts +3 -2
- package/src/mod.ts +6 -1
- package/src/query/cursor.test.ts +113 -68
- package/src/query/cursor.ts +127 -36
- package/src/query/query.ts +19 -0
- package/src/query/unit-of-work.ts +133 -15
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { drizzle } from "drizzle-orm/pglite";
|
|
2
2
|
import { DrizzleAdapter } from "./drizzle-adapter";
|
|
3
|
-
import { beforeAll, describe, expect, expectTypeOf, it } from "vitest";
|
|
3
|
+
import { assert, beforeAll, describe, expect, expectTypeOf, it } from "vitest";
|
|
4
4
|
import { column, idColumn, referenceColumn, schema } from "../../schema/create";
|
|
5
5
|
import type { DBType } from "./shared";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
|
-
import {
|
|
7
|
+
import { Cursor } from "../../query/cursor";
|
|
8
8
|
import type { DrizzleCompiledQuery } from "./drizzle-uow-compiler";
|
|
9
9
|
import { writeAndLoadSchema } from "./test-utils";
|
|
10
10
|
|
|
@@ -245,10 +245,12 @@ describe("DrizzleAdapter PGLite", () => {
|
|
|
245
245
|
|
|
246
246
|
// Create cursor from last item of first page
|
|
247
247
|
const lastItem = firstPage[firstPage.length - 1]!;
|
|
248
|
-
const cursor =
|
|
248
|
+
const cursor = new Cursor({
|
|
249
|
+
indexName: "name_idx",
|
|
250
|
+
orderDirection: "asc",
|
|
251
|
+
pageSize: 2,
|
|
249
252
|
indexValues: { name: lastItem.name },
|
|
250
|
-
|
|
251
|
-
});
|
|
253
|
+
}).encode();
|
|
252
254
|
|
|
253
255
|
// Fetch next page using cursor
|
|
254
256
|
const [secondPage] = await queryEngine
|
|
@@ -729,4 +731,69 @@ describe("DrizzleAdapter PGLite", () => {
|
|
|
729
731
|
"This post was created in the same transaction as the user",
|
|
730
732
|
]);
|
|
731
733
|
});
|
|
734
|
+
|
|
735
|
+
it("should support cursor-based pagination with findWithCursor()", async () => {
|
|
736
|
+
const queryEngine = adapter.createQueryEngine(testSchema, "namespace");
|
|
737
|
+
|
|
738
|
+
// Create multiple users for pagination testing
|
|
739
|
+
for (let i = 1; i <= 25; i++) {
|
|
740
|
+
await queryEngine.create("users", {
|
|
741
|
+
name: `Cursor User ${i.toString().padStart(2, "0")}`,
|
|
742
|
+
age: 20 + i,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Fetch first page with cursor
|
|
747
|
+
const firstPage = await queryEngine.findWithCursor("users", (b) =>
|
|
748
|
+
b.whereIndex("name_idx").orderByIndex("name_idx", "asc").pageSize(10),
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
// Check structure
|
|
752
|
+
expect(firstPage).toHaveProperty("items");
|
|
753
|
+
expect(firstPage).toHaveProperty("cursor");
|
|
754
|
+
expect(Array.isArray(firstPage.items)).toBe(true);
|
|
755
|
+
expect(firstPage.items.length).toBeGreaterThan(0);
|
|
756
|
+
expect(firstPage.items.length).toBeLessThanOrEqual(10);
|
|
757
|
+
|
|
758
|
+
assert(firstPage.cursor instanceof Cursor);
|
|
759
|
+
expect(firstPage.items).toHaveLength(10);
|
|
760
|
+
expect(firstPage.cursor).toBeInstanceOf(Cursor);
|
|
761
|
+
|
|
762
|
+
// Fetch second page using cursor
|
|
763
|
+
const secondPage = await queryEngine.findWithCursor("users", (b) =>
|
|
764
|
+
b
|
|
765
|
+
.whereIndex("name_idx")
|
|
766
|
+
.after(firstPage.cursor!)
|
|
767
|
+
.orderByIndex("name_idx", "asc")
|
|
768
|
+
.pageSize(10),
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
expect(secondPage.items.length).toBeGreaterThan(0);
|
|
772
|
+
expect(secondPage.items.length).toBeLessThanOrEqual(10);
|
|
773
|
+
|
|
774
|
+
// Verify no overlap - first item of second page should come after last item of first page
|
|
775
|
+
const firstPageLastName = firstPage.items[firstPage.items.length - 1].name;
|
|
776
|
+
const secondPageFirstName = secondPage.items[0].name;
|
|
777
|
+
expect(secondPageFirstName > firstPageLastName).toBe(true);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("should support findWithCursor() in Unit of Work", async () => {
|
|
781
|
+
const queryEngine = adapter.createQueryEngine(testSchema, "namespace");
|
|
782
|
+
|
|
783
|
+
// Use findWithCursor in UOW
|
|
784
|
+
const uow = queryEngine
|
|
785
|
+
.createUnitOfWork("cursor-test")
|
|
786
|
+
.findWithCursor("users", (b) =>
|
|
787
|
+
b.whereIndex("name_idx").orderByIndex("name_idx", "asc").pageSize(5),
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
const [result] = await uow.executeRetrieve();
|
|
791
|
+
|
|
792
|
+
// Verify result structure
|
|
793
|
+
expect(result).toHaveProperty("items");
|
|
794
|
+
expect(result).toHaveProperty("cursor");
|
|
795
|
+
expect(Array.isArray(result.items)).toBe(true);
|
|
796
|
+
expect(result.items).toHaveLength(5);
|
|
797
|
+
expect(result.cursor).toBeInstanceOf(Cursor);
|
|
798
|
+
});
|
|
732
799
|
});
|
|
@@ -6,7 +6,7 @@ import { column, idColumn, referenceColumn, schema } from "../../schema/create";
|
|
|
6
6
|
import type { DBType } from "./shared";
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
8
|
import { writeAndLoadSchema } from "./test-utils";
|
|
9
|
-
import {
|
|
9
|
+
import { Cursor } from "../../query/cursor";
|
|
10
10
|
|
|
11
11
|
// Import drizzle-kit for migrations
|
|
12
12
|
const require = createRequire(import.meta.url);
|
|
@@ -272,10 +272,12 @@ describe("DrizzleAdapter SQLite", () => {
|
|
|
272
272
|
|
|
273
273
|
// Create cursor from last item of first page
|
|
274
274
|
const lastItem = firstPage[firstPage.length - 1]!;
|
|
275
|
-
const cursor =
|
|
275
|
+
const cursor = new Cursor({
|
|
276
|
+
indexName: "name_idx",
|
|
277
|
+
orderDirection: "asc",
|
|
278
|
+
pageSize: 2,
|
|
276
279
|
indexValues: { name: lastItem.name },
|
|
277
|
-
|
|
278
|
-
});
|
|
280
|
+
}).encode();
|
|
279
281
|
|
|
280
282
|
// Fetch next page using cursor
|
|
281
283
|
const [secondPage] = await queryEngine
|
|
@@ -8,6 +8,7 @@ import { parseDrizzle, type DrizzleResult, type TableNameMapper, type DBType } f
|
|
|
8
8
|
import { createDrizzleUOWDecoder } from "./drizzle-uow-decoder";
|
|
9
9
|
import type { ConnectionPool } from "../../shared/connection-pool";
|
|
10
10
|
import type { TableToUpdateValues } from "../../query/query";
|
|
11
|
+
import type { CursorResult } from "../../query/cursor";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Configuration options for creating a Drizzle Unit of Work
|
|
@@ -153,6 +154,14 @@ export function fromDrizzle<T extends AnySchema>(
|
|
|
153
154
|
return result;
|
|
154
155
|
},
|
|
155
156
|
|
|
157
|
+
async findWithCursor(tableName, builderFn) {
|
|
158
|
+
// Safe: builderFn returns a FindBuilder, which matches UnitOfWork signature
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
160
|
+
const uow = createUOW({ config: uowConfig }).findWithCursor(tableName, builderFn as any);
|
|
161
|
+
const [result] = await uow.executeRetrieve();
|
|
162
|
+
return result as CursorResult<unknown>;
|
|
163
|
+
},
|
|
164
|
+
|
|
156
165
|
async findFirst(tableName, builderFn) {
|
|
157
166
|
const uow = createUOW({ config: uowConfig });
|
|
158
167
|
if (builderFn) {
|
|
@@ -14,6 +14,7 @@ import { UnitOfWork, type UOWDecoder } from "../../query/unit-of-work";
|
|
|
14
14
|
import { writeAndLoadSchema } from "./test-utils";
|
|
15
15
|
import type { ConnectionPool } from "../../shared/connection-pool";
|
|
16
16
|
import { createDrizzleConnectionPool } from "./drizzle-connection-pool";
|
|
17
|
+
import { Cursor } from "../../query/cursor";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Integration tests for Drizzle UOW compiler and executor.
|
|
@@ -233,7 +234,12 @@ describe("drizzle-uow-compiler", () => {
|
|
|
233
234
|
|
|
234
235
|
it("should compile find operation with cursor pagination using after", () => {
|
|
235
236
|
const uow = createTestUOW();
|
|
236
|
-
const cursor =
|
|
237
|
+
const cursor = new Cursor({
|
|
238
|
+
indexName: "idx_name",
|
|
239
|
+
orderDirection: "asc",
|
|
240
|
+
pageSize: 10,
|
|
241
|
+
indexValues: { name: "Alice" },
|
|
242
|
+
});
|
|
237
243
|
uow.find("users", (b) =>
|
|
238
244
|
b.whereIndex("idx_name").orderByIndex("idx_name", "asc").after(cursor).pageSize(10),
|
|
239
245
|
);
|
|
@@ -250,7 +256,12 @@ describe("drizzle-uow-compiler", () => {
|
|
|
250
256
|
|
|
251
257
|
it("should compile find operation with cursor pagination using before", () => {
|
|
252
258
|
const uow = createTestUOW();
|
|
253
|
-
const cursor =
|
|
259
|
+
const cursor = new Cursor({
|
|
260
|
+
indexName: "idx_name",
|
|
261
|
+
orderDirection: "desc",
|
|
262
|
+
pageSize: 10,
|
|
263
|
+
indexValues: { name: "Bob" },
|
|
264
|
+
});
|
|
254
265
|
uow.find("users", (b) =>
|
|
255
266
|
b.whereIndex("idx_name").orderByIndex("idx_name", "desc").before(cursor).pageSize(10),
|
|
256
267
|
);
|
|
@@ -267,7 +278,12 @@ describe("drizzle-uow-compiler", () => {
|
|
|
267
278
|
|
|
268
279
|
it("should compile find operation with cursor pagination and additional where conditions", () => {
|
|
269
280
|
const uow = createTestUOW();
|
|
270
|
-
const cursor =
|
|
281
|
+
const cursor = new Cursor({
|
|
282
|
+
indexName: "idx_name",
|
|
283
|
+
orderDirection: "asc",
|
|
284
|
+
pageSize: 5,
|
|
285
|
+
indexValues: { name: "Alice" },
|
|
286
|
+
});
|
|
271
287
|
uow.find("users", (b) =>
|
|
272
288
|
b
|
|
273
289
|
.whereIndex("idx_name", (eb) => eb("name", "starts with", "John"))
|
|
@@ -423,8 +423,9 @@ export function createDrizzleUOWCompiler<TSchema extends AnySchema>(
|
|
|
423
423
|
// Add cursor-based pagination conditions
|
|
424
424
|
if ((after || before) && indexColumns.length > 0) {
|
|
425
425
|
const cursor = after || before;
|
|
426
|
-
|
|
427
|
-
const
|
|
426
|
+
// Decode cursor if it's a string, otherwise use it as-is
|
|
427
|
+
const cursorObj = typeof cursor === "string" ? decodeCursor(cursor!) : cursor!;
|
|
428
|
+
const serializedValues = serializeCursorValues(cursorObj, indexColumns, provider);
|
|
428
429
|
|
|
429
430
|
// Build tuple comparison for cursor pagination
|
|
430
431
|
// For "after" with "asc": (col1, col2, ...) > (val1, val2, ...)
|
|
@@ -4,6 +4,7 @@ import type { RetrievalOperation, UOWDecoder } from "../../query/unit-of-work";
|
|
|
4
4
|
import { decodeResult } from "../../query/result-transform";
|
|
5
5
|
import { getOrderedJoinColumns } from "./join-column-utils";
|
|
6
6
|
import type { DrizzleResult } from "./shared";
|
|
7
|
+
import { createCursorFromRecord, Cursor, type CursorResult } from "../../query/cursor";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Join information with nested join support
|
|
@@ -173,10 +174,48 @@ export function createDrizzleUOWDecoder<TSchema extends AnySchema>(
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
// Handle find operations - decode each row
|
|
176
|
-
|
|
177
|
+
const decodedRows = result.rows.map((row) => {
|
|
177
178
|
const transformedRow = transformJoinArraysToObjects(row, op, provider);
|
|
178
179
|
return decodeResult(transformedRow, op.table, provider);
|
|
179
180
|
});
|
|
181
|
+
|
|
182
|
+
// If cursor generation is requested, wrap in CursorResult
|
|
183
|
+
if (op.withCursor) {
|
|
184
|
+
let cursor: Cursor | undefined;
|
|
185
|
+
|
|
186
|
+
// Generate cursor from last item if results exist
|
|
187
|
+
if (decodedRows.length > 0 && op.options.orderByIndex && op.options.pageSize) {
|
|
188
|
+
const lastItem = decodedRows[decodedRows.length - 1];
|
|
189
|
+
const indexName = op.options.orderByIndex.indexName;
|
|
190
|
+
|
|
191
|
+
// Get index columns
|
|
192
|
+
let indexColumns;
|
|
193
|
+
if (indexName === "_primary") {
|
|
194
|
+
indexColumns = [op.table.getIdColumn()];
|
|
195
|
+
} else {
|
|
196
|
+
const index = op.table.indexes[indexName];
|
|
197
|
+
if (index) {
|
|
198
|
+
indexColumns = index.columns;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (indexColumns && lastItem) {
|
|
203
|
+
cursor = createCursorFromRecord(lastItem as Record<string, unknown>, indexColumns, {
|
|
204
|
+
indexName: op.options.orderByIndex.indexName,
|
|
205
|
+
orderDirection: op.options.orderByIndex.direction,
|
|
206
|
+
pageSize: op.options.pageSize,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const cursorResult: CursorResult<unknown> = {
|
|
212
|
+
items: decodedRows,
|
|
213
|
+
cursor,
|
|
214
|
+
};
|
|
215
|
+
return cursorResult;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return decodedRows;
|
|
180
219
|
});
|
|
181
220
|
};
|
|
182
221
|
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type FragnoId,
|
|
11
11
|
type FragnoReference,
|
|
12
12
|
} from "../../schema/create";
|
|
13
|
-
import {
|
|
13
|
+
import { Cursor } from "../../query/cursor";
|
|
14
14
|
|
|
15
15
|
describe("KyselyAdapter PGLite", () => {
|
|
16
16
|
const testSchema = schema((s) => {
|
|
@@ -396,10 +396,12 @@ describe("KyselyAdapter PGLite", () => {
|
|
|
396
396
|
|
|
397
397
|
// Get cursor for pagination (using the last item from page 1)
|
|
398
398
|
const lastItem = page1Results[page1Results.length - 1]!;
|
|
399
|
-
const cursor =
|
|
399
|
+
const cursor = new Cursor({
|
|
400
|
+
indexName: "name_idx",
|
|
401
|
+
orderDirection: "asc",
|
|
402
|
+
pageSize: 2,
|
|
400
403
|
indexValues: { name: lastItem.name },
|
|
401
|
-
|
|
402
|
-
});
|
|
404
|
+
}).encode();
|
|
403
405
|
|
|
404
406
|
// Get page 2 using the cursor
|
|
405
407
|
const page2 = queryEngine
|
|
@@ -872,4 +874,100 @@ describe("KyselyAdapter PGLite", () => {
|
|
|
872
874
|
// Verify the foreign key relationship is correct
|
|
873
875
|
expect(post?.user_id.internalId).toBe(user?.id.internalId);
|
|
874
876
|
});
|
|
877
|
+
|
|
878
|
+
it("should support cursor-based pagination with findWithCursor()", async () => {
|
|
879
|
+
const queryEngine = adapter.createQueryEngine(testSchema, "test");
|
|
880
|
+
|
|
881
|
+
// Create multiple users for pagination testing with unique prefix
|
|
882
|
+
const prefix = "CursorPagTest";
|
|
883
|
+
const userIds: FragnoId[] = [];
|
|
884
|
+
for (let i = 1; i <= 25; i++) {
|
|
885
|
+
const userId = await queryEngine.create("users", {
|
|
886
|
+
name: `${prefix} ${i.toString().padStart(2, "0")}`,
|
|
887
|
+
age: 20 + i,
|
|
888
|
+
});
|
|
889
|
+
userIds.push(userId);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Fetch first page with cursor (filter by prefix to avoid other test data)
|
|
893
|
+
const firstPage = await queryEngine.findWithCursor("users", (b) =>
|
|
894
|
+
b.whereIndex("name_idx").orderByIndex("name_idx", "asc").pageSize(10),
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
// Check structure
|
|
898
|
+
expect(firstPage).toHaveProperty("items");
|
|
899
|
+
expect(firstPage).toHaveProperty("cursor");
|
|
900
|
+
expect(Array.isArray(firstPage.items)).toBe(true);
|
|
901
|
+
expect(firstPage.items.length).toBeGreaterThan(0);
|
|
902
|
+
|
|
903
|
+
assert(firstPage.cursor instanceof Cursor);
|
|
904
|
+
|
|
905
|
+
// Fetch second page using cursor
|
|
906
|
+
const secondPage = await queryEngine.findWithCursor("users", (b) =>
|
|
907
|
+
b
|
|
908
|
+
.whereIndex("name_idx")
|
|
909
|
+
.after(firstPage.cursor!)
|
|
910
|
+
.orderByIndex("name_idx", "asc")
|
|
911
|
+
.pageSize(10),
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
expect(secondPage.items.length).toBeGreaterThan(0);
|
|
915
|
+
|
|
916
|
+
// Verify no overlap - all names in second page should be different from first page
|
|
917
|
+
const firstPageNames = new Set(firstPage.items.map((u) => u.name));
|
|
918
|
+
const secondPageNames = secondPage.items.map((u) => u.name);
|
|
919
|
+
|
|
920
|
+
for (const name of secondPageNames) {
|
|
921
|
+
expect(firstPageNames.has(name)).toBe(false);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Verify ordering - last item of first page should come before first item of second page
|
|
925
|
+
const firstPageLast = firstPage.items[firstPage.items.length - 1].name;
|
|
926
|
+
const secondPageFirst = secondPage.items[0].name;
|
|
927
|
+
expect(firstPageLast < secondPageFirst).toBe(true);
|
|
928
|
+
|
|
929
|
+
// Verify our test data is present
|
|
930
|
+
const testUsers = await queryEngine.find("users", (b) =>
|
|
931
|
+
b.whereIndex("name_idx").pageSize(100),
|
|
932
|
+
);
|
|
933
|
+
const testUserNames = testUsers.filter((u) => u.name.startsWith(prefix)).map((u) => u.name);
|
|
934
|
+
expect(testUserNames).toHaveLength(25);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it("should support findWithCursor() in Unit of Work", async () => {
|
|
938
|
+
const queryEngine = adapter.createQueryEngine(testSchema, "test");
|
|
939
|
+
|
|
940
|
+
// Create test users if not already present
|
|
941
|
+
const existingUsers = await queryEngine.find("users", (b) =>
|
|
942
|
+
b.whereIndex("name_idx").pageSize(1),
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
if (existingUsers.length === 0) {
|
|
946
|
+
for (let i = 1; i <= 5; i++) {
|
|
947
|
+
await queryEngine.create("users", {
|
|
948
|
+
name: `UOW Cursor User ${i}`,
|
|
949
|
+
age: 30 + i,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Use findWithCursor in UOW
|
|
955
|
+
const uow = queryEngine
|
|
956
|
+
.createUnitOfWork("cursor-test")
|
|
957
|
+
.findWithCursor("users", (b) =>
|
|
958
|
+
b.whereIndex("name_idx").orderByIndex("name_idx", "asc").pageSize(3),
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
const [result] = await uow.executeRetrieve();
|
|
962
|
+
|
|
963
|
+
// Verify result structure
|
|
964
|
+
expect(result).toHaveProperty("items");
|
|
965
|
+
expect(result).toHaveProperty("cursor");
|
|
966
|
+
expect(Array.isArray(result.items)).toBe(true);
|
|
967
|
+
expect(result.items.length).toBeGreaterThan(0);
|
|
968
|
+
|
|
969
|
+
if (result.items.length === 3) {
|
|
970
|
+
expect(result.cursor).toBeInstanceOf(Cursor);
|
|
971
|
+
}
|
|
972
|
+
});
|
|
875
973
|
});
|
|
@@ -14,6 +14,7 @@ import type { CompiledQuery, Kysely } from "kysely";
|
|
|
14
14
|
import type { TableNameMapper } from "./kysely-shared";
|
|
15
15
|
import type { ConnectionPool } from "../../shared/connection-pool";
|
|
16
16
|
import type { SQLProvider } from "../../shared/providers";
|
|
17
|
+
import { createCursorFromRecord, Cursor, type CursorResult } from "../../query/cursor";
|
|
17
18
|
|
|
18
19
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
20
|
type KyselyAny = Kysely<any>;
|
|
@@ -159,7 +160,45 @@ export function fromKysely<T extends AnySchema>(
|
|
|
159
160
|
|
|
160
161
|
// Each result is an array of rows - decode each row
|
|
161
162
|
const rowArray = rows as Record<string, unknown>[];
|
|
162
|
-
|
|
163
|
+
const decodedRows = rowArray.map((row) => decodeResult(row, op.table, provider));
|
|
164
|
+
|
|
165
|
+
// If cursor generation is requested, wrap in CursorResult
|
|
166
|
+
if (op.withCursor) {
|
|
167
|
+
let cursor: Cursor | undefined;
|
|
168
|
+
|
|
169
|
+
// Generate cursor from last item if results exist
|
|
170
|
+
if (decodedRows.length > 0 && op.options.orderByIndex && op.options.pageSize) {
|
|
171
|
+
const lastItem = decodedRows[decodedRows.length - 1];
|
|
172
|
+
const indexName = op.options.orderByIndex.indexName;
|
|
173
|
+
|
|
174
|
+
// Get index columns
|
|
175
|
+
let indexColumns;
|
|
176
|
+
if (indexName === "_primary") {
|
|
177
|
+
indexColumns = [op.table.getIdColumn()];
|
|
178
|
+
} else {
|
|
179
|
+
const index = op.table.indexes[indexName];
|
|
180
|
+
if (index) {
|
|
181
|
+
indexColumns = index.columns;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (indexColumns && lastItem) {
|
|
186
|
+
cursor = createCursorFromRecord(lastItem as Record<string, unknown>, indexColumns, {
|
|
187
|
+
indexName: op.options.orderByIndex.indexName,
|
|
188
|
+
orderDirection: op.options.orderByIndex.direction,
|
|
189
|
+
pageSize: op.options.pageSize,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const result: CursorResult<unknown> = {
|
|
195
|
+
items: decodedRows,
|
|
196
|
+
cursor,
|
|
197
|
+
};
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return decodedRows;
|
|
163
202
|
});
|
|
164
203
|
};
|
|
165
204
|
|
|
@@ -193,6 +232,16 @@ export function fromKysely<T extends AnySchema>(
|
|
|
193
232
|
return result ?? [];
|
|
194
233
|
},
|
|
195
234
|
|
|
235
|
+
async findWithCursor(tableName, builderFn) {
|
|
236
|
+
// Safe: builderFn returns a FindBuilder, which matches UnitOfWork signature
|
|
237
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
238
|
+
const uow = createUOW({ config: uowConfig }).findWithCursor(tableName, builderFn as any);
|
|
239
|
+
// executeRetrieve returns an array of results (one per find operation)
|
|
240
|
+
// Since we only have one findWithCursor, unwrap the first result
|
|
241
|
+
const [result] = await uow.executeRetrieve();
|
|
242
|
+
return result as CursorResult<unknown>;
|
|
243
|
+
},
|
|
244
|
+
|
|
196
245
|
async findFirst(tableName, builderFn) {
|
|
197
246
|
const uow = createUOW({ config: uowConfig });
|
|
198
247
|
if (builderFn) {
|
|
@@ -5,6 +5,7 @@ import { UnitOfWork, type UOWDecoder } from "../../query/unit-of-work";
|
|
|
5
5
|
import { createKyselyUOWCompiler } from "./kysely-uow-compiler";
|
|
6
6
|
import type { ConnectionPool } from "../../shared/connection-pool";
|
|
7
7
|
import { createKyselyConnectionPool } from "./kysely-connection-pool";
|
|
8
|
+
import { Cursor } from "../../query/cursor";
|
|
8
9
|
|
|
9
10
|
describe("kysely-uow-compiler", () => {
|
|
10
11
|
const testSchema = schema((s) => {
|
|
@@ -252,7 +253,12 @@ describe("kysely-uow-compiler", () => {
|
|
|
252
253
|
|
|
253
254
|
it("should compile find operation with cursor pagination using after", () => {
|
|
254
255
|
const uow = createTestUOW();
|
|
255
|
-
const cursor =
|
|
256
|
+
const cursor = new Cursor({
|
|
257
|
+
indexName: "idx_name",
|
|
258
|
+
orderDirection: "asc",
|
|
259
|
+
pageSize: 10,
|
|
260
|
+
indexValues: { name: "Alice" },
|
|
261
|
+
});
|
|
256
262
|
uow.find("users", (b) =>
|
|
257
263
|
b.whereIndex("idx_name").orderByIndex("idx_name", "asc").after(cursor).pageSize(10),
|
|
258
264
|
);
|
|
@@ -269,7 +275,12 @@ describe("kysely-uow-compiler", () => {
|
|
|
269
275
|
|
|
270
276
|
it("should compile find operation with cursor pagination using before", () => {
|
|
271
277
|
const uow = createTestUOW();
|
|
272
|
-
const cursor =
|
|
278
|
+
const cursor = new Cursor({
|
|
279
|
+
indexName: "idx_name",
|
|
280
|
+
orderDirection: "desc",
|
|
281
|
+
pageSize: 10,
|
|
282
|
+
indexValues: { name: "Bob" },
|
|
283
|
+
});
|
|
273
284
|
uow.find("users", (b) =>
|
|
274
285
|
b.whereIndex("idx_name").orderByIndex("idx_name", "desc").before(cursor).pageSize(10),
|
|
275
286
|
);
|
|
@@ -286,7 +297,12 @@ describe("kysely-uow-compiler", () => {
|
|
|
286
297
|
|
|
287
298
|
it("should compile find operation with cursor pagination and additional where conditions", () => {
|
|
288
299
|
const uow = createTestUOW();
|
|
289
|
-
const cursor =
|
|
300
|
+
const cursor = new Cursor({
|
|
301
|
+
indexName: "idx_name",
|
|
302
|
+
orderDirection: "asc",
|
|
303
|
+
pageSize: 5,
|
|
304
|
+
indexValues: { name: "Alice" },
|
|
305
|
+
});
|
|
290
306
|
uow.find("users", (b) =>
|
|
291
307
|
b
|
|
292
308
|
.whereIndex("idx_name", (eb) => eb("name", "starts with", "John"))
|
|
@@ -105,8 +105,9 @@ export function createKyselyUOWCompiler<TSchema extends AnySchema>(
|
|
|
105
105
|
|
|
106
106
|
if ((after || before) && indexColumns.length > 0) {
|
|
107
107
|
const cursor = after || before;
|
|
108
|
-
|
|
109
|
-
const
|
|
108
|
+
// Decode cursor if it's a string, otherwise use it as-is
|
|
109
|
+
const cursorObj = typeof cursor === "string" ? decodeCursor(cursor!) : cursor!;
|
|
110
|
+
const serializedValues = serializeCursorValues(cursorObj, indexColumns, provider);
|
|
110
111
|
|
|
111
112
|
// Build tuple comparison for cursor pagination
|
|
112
113
|
// For "after" with "asc": (col1, col2, ...) > (val1, val2, ...)
|
package/src/mod.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { DatabaseAdapter } from "./adapters/adapters";
|
|
2
2
|
import type { AnySchema } from "./schema/create";
|
|
3
3
|
import type { AbstractQuery } from "./query/query";
|
|
4
|
+
import type { CursorResult } from "./query/cursor";
|
|
5
|
+
import { Cursor } from "./query/cursor";
|
|
4
6
|
|
|
5
|
-
export type { DatabaseAdapter };
|
|
7
|
+
export type { DatabaseAdapter, CursorResult };
|
|
8
|
+
export { Cursor };
|
|
6
9
|
|
|
7
10
|
export const fragnoDatabaseFakeSymbol = "$fragno-database" as const;
|
|
8
11
|
export const fragnoDatabaseLibraryVersion = "0.1" as const;
|
|
@@ -129,3 +132,5 @@ export {
|
|
|
129
132
|
type FragnoPublicConfigWithDatabase,
|
|
130
133
|
type DatabaseFragmentContext,
|
|
131
134
|
} from "./fragment";
|
|
135
|
+
|
|
136
|
+
export { decodeCursor, type CursorData } from "./query/cursor";
|