ReviewOS

also looking at this

stacks/bun-query-builder

feat(paginate): add a spelling that cannot be transposed

#1130
Merged glennmichael123 wants to merge fix/1092-paginate-options into main
3 files +208 -5
packages/bun-query-builder/test/paginate-options.test.tsadded+153-0
Changes to packages/bun-query-builder/test/paginate-options.test.ts
@@ -0,0 +1,153 @@
1/**
2 * paginate() gains a spelling that cannot be transposed.
3 * stacksjs/bun-query-builder#1092.
4 *
5 * The two paginate APIs take the same two numbers in opposite orders:
6 *
7 * db.selectFrom(t).paginate(perPage, page)
8 * Model.query().paginate(page, perPage)
9 *
10 * Both parameters are `number`, so getting it backwards is not a type error —
11 * you simply receive a different page, with no warning. `forPage(page, perPage)`
12 * sits ~60 lines from the builder's `paginate(perPage, page)` and takes the
13 * opposite order again, which is presumably where this came from.
14 *
15 * Resolved additively, deliberately: realigning either side would silently
16 * change which rows every existing caller gets. So both APIs now also accept
17 * `{ page, perPage }`, both keep their positional forms unchanged, and the
18 * ORM's result gains a `meta` object mirroring the builder's while keeping its
19 * flat fields. Nobody's call moves; there is now a way to write one that
20 * cannot be got wrong, and a shape that reads the same from either API.
21 *
22 * The positional divergence itself is left for a deliberate major.
23 */
24
25import { mkdtempSync, rmSync } from 'node:fs'
26import { tmpdir } from 'node:os'
27import { join } from 'node:path'
28import { Database } from 'bun:sqlite'
29import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
30import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src'
31import { clearModelRegistry, configureOrm, createModel, createTableFromModel } from '../src'
32import { config, setConfig } from '../src/config'
33import { resetConnection } from '../src/db'
34
35const MODELS = {
36 t: { columns: { id: { type: 'integer', isPrimaryKey: true }, name: { type: 'string' } } },
37} as any
38
39let dir: string
40let dbPath: string
41let snapshot: { dialect: string, database: Record<string, unknown> }
42
43function qb(): any {
44 return createQueryBuilder({
45 schema: buildDatabaseSchema(MODELS),
46 meta: buildSchemaMeta(MODELS),
47 autoMigration: { enabled: false } as any,
48 })
49}
50
51beforeEach(() => {
52 snapshot = { dialect: config.dialect, database: { ...config.database } }
53 dir = mkdtempSync(join(tmpdir(), 'qb-1092-'))
54 dbPath = join(dir, 'page.sqlite')
55 const seed = new Database(dbPath, { create: true })
56 seed.run('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)')
57 for (let i = 1; i <= 20; i++) seed.run(`INSERT INTO t (id,name) VALUES (${i},'n${i}')`)
58 seed.close()
59 setConfig({ dialect: 'sqlite', database: { database: dbPath } } as any)
60 resetConnection()
61})
62
63afterEach(() => {
64 config.dialect = snapshot.dialect as any
65 for (const k of Object.keys(config.database)) delete (config.database as any)[k]
66 Object.assign(config.database, snapshot.database)
67 resetConnection()
68 rmSync(dir, { recursive: true, force: true })
69})
70
71describe('the builder accepts the options form (#1092)', () => {
72 it('{ perPage, page } means the same as the positional call', async () => {
73 const byOptions = await qb().selectFrom('t').selectAll().paginate({ perPage: 5, page: 2 })
74 const byPosition = await qb().selectFrom('t').selectAll().paginate(5, 2)
75
76 expect(byOptions.data.map((r: any) => r.id)).toEqual([6, 7, 8, 9, 10])
77 expect(byOptions.data).toEqual(byPosition.data)
78 expect(byOptions.meta).toEqual({ perPage: 5, page: 2, total: 20, lastPage: 4 })
79 })
80
81 it('the positional form is untouched', async () => {
82 const res = await qb().selectFrom('t').selectAll().paginate(5, 2)
83 expect(res.data.map((r: any) => r.id)).toEqual([6, 7, 8, 9, 10])
84 })
85
86 it('page defaults to 1', async () => {
87 const res = await qb().selectFrom('t').selectAll().paginate({ perPage: 3 })
88 expect(res.data.map((r: any) => r.id)).toEqual([1, 2, 3])
89 expect(res.meta.page).toBe(1)
90 })
91
92 it('the existing validation still applies through the options form', async () => {
93 await expect(qb().selectFrom('t').selectAll().paginate({ perPage: 0 }))
94 .rejects.toThrow(/expected positive integer/)
95 await expect(qb().selectFrom('t').selectAll().paginate({ perPage: 5, page: 0 }))
96 .rejects.toThrow(/expected integer >= 1/)
97 })
98})
99
100describe('the ORM accepts the options form and reports meta (#1092)', () => {
101 let Post: any
102
103 beforeEach(async () => {
104 clearModelRegistry()
105 const db = new Database(join(dir, 'orm.sqlite'), { create: true })
106 configureOrm({ database: db })
107 Post = createModel({
108 name: 'Pgpost',
109 table: 'pg_posts',
110 primaryKey: 'id',
111 autoIncrement: true,
112 attributes: { title: { type: 'string', fillable: true } },
113 } as any)
114 await createTableFromModel(Post.getDefinition())
115 for (let i = 1; i <= 20; i++) await Post.create({ title: `t${i}` })
116 })
117
118 afterEach(() => clearModelRegistry())
119
120 it('{ perPage, page } selects the same page the builder would', async () => {
121 const res = await Post.query().paginate({ perPage: 5, page: 2 })
122
123 // The positional ORM call for this page is paginate(2, 5) — the transposed
124 // order that made this worth fixing.
125 expect(res.data.map((r: any) => r.id)).toEqual([6, 7, 8, 9, 10])
126 expect(res.page).toBe(2)
127 expect(res.perPage).toBe(5)
128 })
129
130 it('the positional form keeps its own order', async () => {
131 const res = await Post.query().paginate(2, 5)
132 expect(res.data.map((r: any) => r.id)).toEqual([6, 7, 8, 9, 10])
133 })
134
135 it('the flat fields are unchanged', async () => {
136 const res = await Post.query().paginate({ perPage: 5, page: 2 })
137
138 expect(res.total).toBe(20)
139 expect(res.lastPage).toBe(4)
140 expect(res.hasMorePages).toBe(true)
141 expect(res.isEmpty).toBe(false)
142 expect(res.from).toBe(6)
143 expect(res.to).toBe(10)
144 })
145
146 it('and meta now reads the same as the builder\'s', async () => {
147 const orm = await Post.query().paginate({ perPage: 5, page: 2 })
148 const builder = await qb().selectFrom('t').selectAll().paginate({ perPage: 5, page: 2 })
149
150 // The point of the addition: one accessor works against either API.
151 expect(orm.meta).toEqual(builder.meta)
152 })
153})