diff --git a/docs/mysql-typed.md b/docs/mysql-typed.md index 1eb05126..ca5cd3e9 100644 --- a/docs/mysql-typed.md +++ b/docs/mysql-typed.md @@ -219,6 +219,34 @@ export async function getEmailsAlphabetical() { } ``` +### offset(count) + +Skip the first `count` rows. This is generally a less efficient method of pagination than using a field in the query as a "next page token". You can only use this method if you have first called `orderByAsc` or `orderByDesc` at least once. + +```typescript +import db, {users} from './database'; + +export async function paginatedEmails(page?: number) { + const records = await users(db) + .find() + .orderByAsc(`email`) + .offset(10 * (page ?? 0)) + .limit(10); + return records.map((record) => record.email) +} + +export async function printAllEmails() { + let pageNumber = 0 + let records = await paginatedEmails(pageNumber++); + while (records.length) { + for (const email of records) { + console.log(email); + } + records = await paginatedEmails(pageNumber++); + } +} +``` + ### limit(count) Return the first `count` rows. N.B. you can only use this method if you have first called `orderByAsc` or `orderByDesc` at least once. diff --git a/docs/pg-typed.md b/docs/pg-typed.md index 4e1031ae..46d42825 100644 --- a/docs/pg-typed.md +++ b/docs/pg-typed.md @@ -545,6 +545,34 @@ export async function getOldestPostVersions() { } ``` +### offset(count) + +Skip the first `count` rows. This is generally a less efficient method of pagination than using a field in the query as a "next page token". You can only use this method if you have first called `orderByAsc` or `orderByDesc` at least once. + +```typescript +import db, {users} from './database'; + +export async function paginatedEmails(page?: number) { + const records = await users(db) + .find() + .orderByAsc(`email`) + .offset(10 * (page ?? 0)) + .limit(10); + return records.map((record) => record.email) +} + +export async function printAllEmails() { + let pageNumber = 0 + let records = await paginatedEmails(pageNumber++); + while (records.length) { + for (const email of records) { + console.log(email); + } + records = await paginatedEmails(pageNumber++); + } +} +``` + ### limit(count) Return the first `count` rows. N.B. you can only use this method if you have first called `orderByAsc` or `orderByDesc` at least once. diff --git a/packages/mock-db-typed/src/index.ts b/packages/mock-db-typed/src/index.ts index cfeb984b..77c6557c 100644 --- a/packages/mock-db-typed/src/index.ts +++ b/packages/mock-db-typed/src/index.ts @@ -10,11 +10,15 @@ export interface SelectQuery { ...fields: TKeys ): SelectQuery>; } - -export interface OrderedSelectQuery extends SelectQuery { +export interface OrderedSelectQueryWithOffset + extends SelectQuery { first(): Promise; limit(count: number): Promise; } +export interface OrderedSelectQuery + extends OrderedSelectQueryWithOffset { + offset(count: number): OrderedSelectQueryWithOffset; +} class FieldQuery { protected readonly __query: ( @@ -134,6 +138,7 @@ class SelectQueryImplementation { public readonly orderByQueries: SQLQuery[] = []; public limitCount: number | undefined; + public offsetCount: number | undefined; private _selectFields: SQLQuery | undefined; constructor( @@ -164,6 +169,9 @@ class SelectQueryImplementation if (this.limitCount) { parts.push(sql`LIMIT ${this.limitCount}`); } + if (this.offsetCount) { + parts.push(sql`OFFSET ${this.offsetCount}`); + } return this._executeQuery( parts.length === 1 ? parts[0] : sql.join(parts, sql` `), ); @@ -205,6 +213,18 @@ class SelectQueryImplementation this.limitCount = count; return await this._getResults('limit'); } + public offset(offset: number) { + if (!this.orderByQueries.length) { + throw new Error( + 'You cannot call "offset" until after you call "orderByAsc" or "orderByDesc".', + ); + } + if (this.offsetCount !== undefined) { + throw new Error('You cannot call "offset" multiple times'); + } + this.offsetCount = offset; + return this; + } public async first() { if (!this.orderByQueries.length) { throw new Error( diff --git a/packages/mysql-typed/src/__tests__/index.test.mysql.ts b/packages/mysql-typed/src/__tests__/index.test.mysql.ts index 1e1c3001..e6b06e3a 100644 --- a/packages/mysql-typed/src/__tests__/index.test.mysql.ts +++ b/packages/mysql-typed/src/__tests__/index.test.mysql.ts @@ -115,6 +115,18 @@ t('create users', async () => { ] `); + const photoRecordsOffset = await photos(db) + .find({owner_user_id: 1}) + .orderByAsc('cdn_url') + .offset(1) + .limit(2); + expect(photoRecords.map((p) => p.cdn_url)).toMatchInlineSnapshot(` + Array [ + "http://example.com/2", + "http://example.com/3", + ] + `); + const photoRecordsDesc = await photos(db) .find({owner_user_id: 1}) .orderByDesc('cdn_url') diff --git a/packages/mysql-typed/src/index.ts b/packages/mysql-typed/src/index.ts index 29455971..4491269e 100644 --- a/packages/mysql-typed/src/index.ts +++ b/packages/mysql-typed/src/index.ts @@ -10,11 +10,15 @@ export interface SelectQuery { ...fields: TKeys ): SelectQuery>; } - -export interface OrderedSelectQuery extends SelectQuery { +export interface OrderedSelectQueryWithOffset + extends SelectQuery { first(): Promise; limit(count: number): Promise; } +export interface OrderedSelectQuery + extends OrderedSelectQueryWithOffset { + offset(count: number): OrderedSelectQueryWithOffset; +} class FieldQuery { protected readonly __query: ( @@ -134,6 +138,7 @@ class SelectQueryImplementation { public readonly orderByQueries: SQLQuery[] = []; public limitCount: number | undefined; + public offsetCount: number | undefined; private _selectFields: SQLQuery | undefined; constructor( @@ -164,6 +169,9 @@ class SelectQueryImplementation if (this.limitCount) { parts.push(sql`LIMIT ${this.limitCount}`); } + if (this.offsetCount) { + parts.push(sql`OFFSET ${this.offsetCount}`); + } return this._executeQuery( parts.length === 1 ? parts[0] : sql.join(parts, sql` `), ); @@ -205,6 +213,18 @@ class SelectQueryImplementation this.limitCount = count; return await this._getResults('limit'); } + public offset(offset: number) { + if (!this.orderByQueries.length) { + throw new Error( + 'You cannot call "offset" until after you call "orderByAsc" or "orderByDesc".', + ); + } + if (this.offsetCount !== undefined) { + throw new Error('You cannot call "offset" multiple times'); + } + this.offsetCount = offset; + return this; + } public async first() { if (!this.orderByQueries.length) { throw new Error( diff --git a/packages/pg-typed/src/__tests__/index.test.pg.ts b/packages/pg-typed/src/__tests__/index.test.pg.ts index 61f586de..005bfd1e 100644 --- a/packages/pg-typed/src/__tests__/index.test.pg.ts +++ b/packages/pg-typed/src/__tests__/index.test.pg.ts @@ -88,6 +88,18 @@ test('create users', async () => { ] `); + const photoRecordsOffset = await photos(db) + .find({owner_user_id: forbes.id}) + .orderByAsc('cdn_url') + .offset(1) + .limit(2); + expect(photoRecordsOffset.map((p) => p.cdn_url)).toMatchInlineSnapshot(` + Array [ + "http://example.com/2", + "http://example.com/3", + ] + `); + const photoRecordsDesc = await photos(db) .find({owner_user_id: forbes.id}) .orderByDesc('cdn_url') diff --git a/packages/pg-typed/src/index.ts b/packages/pg-typed/src/index.ts index 089821b5..2dd5d6bc 100644 --- a/packages/pg-typed/src/index.ts +++ b/packages/pg-typed/src/index.ts @@ -59,6 +59,7 @@ export type UnorderedSelectQueryMethods = export type SelectQueryMethods = | UnorderedSelectQueryMethods | 'first' + | 'offset' | 'limit'; export interface SelectQuery { toSql(): SQLQuery; @@ -68,6 +69,20 @@ export interface SelectQuery { all(): Promise; first(): Promise; limit(count: number): Promise; + offset( + count: number, + ): PartialSelectQuery< + TRecord, + Exclude< + TMethods, + | 'distinct' + | 'orderByAscDistinct' + | 'orderByDescDistinct' + | 'orderByAsc' + | 'orderByDesc' + | 'offset' + > + >; select< TKeys extends readonly [keyof TRecord, ...(readonly (keyof TRecord)[])], @@ -94,13 +109,13 @@ export interface SelectQuery { key: keyof TRecord, ): PartialSelectQuery< TRecord, - Exclude | 'first' | 'limit' + Exclude | 'first' | 'limit' | 'offset' >; orderByDescDistinct( key: keyof TRecord, ): PartialSelectQuery< TRecord, - Exclude | 'first' | 'limit' + Exclude | 'first' | 'limit' | 'offset' >; orderByAsc( key: keyof TRecord, @@ -112,6 +127,7 @@ export interface SelectQuery { > | 'first' | 'limit' + | 'offset' >; orderByDesc( key: keyof TRecord, @@ -123,6 +139,7 @@ export interface SelectQuery { > | 'first' | 'limit' + | 'offset' >; andWhere(condition: WhereCondition): this; } @@ -494,6 +511,7 @@ class SelectQueryImplementation direction: 'ASC' | 'DESC'; }[] = []; private _limitCount: number | undefined; + private _offsetCount: number | undefined; private _selectFields: readonly string[] | undefined; private readonly _whereAnd: WhereCondition[] = []; @@ -525,6 +543,7 @@ class SelectQueryImplementation const selectFields = this._selectFields; const orderByQueries = this._orderByQueries; const limitCount = this._limitCount; + const offsetCount = this._offsetCount; const distinctColumnNames = this._distinctColumnNames; const whereCondition = @@ -578,6 +597,7 @@ class SelectQueryImplementation )}` : null, limitCount ? sql`LIMIT ${limitCount}` : null, + offsetCount ? sql`OFFSET ${offsetCount}` : null, ].filter((v: T): v is Exclude => v !== null), sql` `, ); @@ -661,6 +681,18 @@ class SelectQueryImplementation this._limitCount = count; return await this._getResults('limit'); } + public offset(offset: number) { + if (!this._orderByQueries.length) { + throw new Error( + 'You cannot call "offset" until after you call "orderByAsc" or "orderByDesc".', + ); + } + if (this.offsetCount !== undefined) { + throw new Error('You cannot call "offset" multiple times'); + } + this._offsetCount = offset; + return this; + } public async first() { if (!this._orderByQueries.length) { throw new Error(