also looking at this
feat(paginate): add a spelling that cannot be transposed
#1130
3 files
+208
-5
| @@ -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 | ||
| 25 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 26 | import { tmpdir } from 'node:os' | |
| 27 | import { join } from 'node:path' | |
| 28 | import { Database } from 'bun:sqlite' | |
| 29 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 30 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src' | |
| 31 | import { clearModelRegistry, configureOrm, createModel, createTableFromModel } from '../src' | |
| 32 | import { config, setConfig } from '../src/config' | |
| 33 | import { resetConnection } from '../src/db' | |
| 34 | ||
| 35 | const MODELS = { | |
| 36 | t: { columns: { id: { type: 'integer', isPrimaryKey: true }, name: { type: 'string' } } }, | |
| 37 | } as any | |
| 38 | ||
| 39 | let dir: string | |
| 40 | let dbPath: string | |
| 41 | let snapshot: { dialect: string, database: Record<string, unknown> } | |
| 42 | ||
| 43 | function qb(): any { | |
| 44 | return createQueryBuilder({ | |
| 45 | schema: buildDatabaseSchema(MODELS), | |
| 46 | meta: buildSchemaMeta(MODELS), | |
| 47 | autoMigration: { enabled: false } as any, | |
| 48 | }) | |
| 49 | } | |
| 50 | ||
| 51 | beforeEach(() => { | |
| 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 | ||
| 63 | afterEach(() => { | |
| 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 | ||
| 71 | describe('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 | ||
| 100 | describe('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 | }) | |