diff --git a/docs/pg-bulk.md b/docs/pg-bulk.md index d65e5937..7bff1f4b 100644 --- a/docs/pg-bulk.md +++ b/docs/pg-bulk.md @@ -149,15 +149,11 @@ This is equivalent to: ```javascript async function getUsers() { await database.query(sql` - SELECT users.email, users.date_of_birth FROM users - INNER JOIN ( + SELECT email, date_of_birth FROM users + WHERE (email, favorite_color) IN ( SELECT - UNNEST(${[`joe@example.com`, `ben@example.com`]}::TEXT[]) AS email, - UNNEST(${[`red`, `blue`]}::TEXT[]) AS favorite_color - ) AS bulk_query - ON ( - users.email=bulk_query.email - AND users.color=bulk_query.color + UNNEST(${['joe@example.com', 'ben@example.com']}::TEXT[]), + UNNEST(${['red', 'blue']}::TEXT[]) ) ORDER BY email ASC LIMIT 100 @@ -216,9 +212,9 @@ async function updateFavoriteColors() { FROM ( SELECT - UNNEST(${[`joe@example.com`, `ben@example.com`]}::TEXT[]) + UNNEST(${['joe@example.com', 'ben@example.com']}::TEXT[]) AS email, - UNNEST(${[`indigo`, `orange`]}::TEXT[]) + UNNEST(${['indigo', 'orange']}::TEXT[]) AS updated_value_of_favorite_color ) AS bulk_query WHERE @@ -271,16 +267,10 @@ This is equivalent to: async function getUsers() { await database.query(sql` DELETE FROM users - WHERE EXISTS( - SELECT * - FROM - ( - UNNEST(${[`joe@example.com`, `ben@example.com`]}::TEXT[]) AS email, - UNNEST(${[`red`, `blue`]}::TEXT[]) AS favorite_color - ) AS bulk_query - WHERE - users.email=bulk_query.email - AND users.color=bulk_query.color + WHERE (email, favorite_color) IN ( + SELECT + UNNEST(${['joe@example.com', 'ben@example.com']}::TEXT[]), + UNNEST(${['red', 'blue']}::TEXT[]) ) `); } diff --git a/docs/pg-typed.md b/docs/pg-typed.md index 74b554f1..4ea135e0 100644 --- a/docs/pg-typed.md +++ b/docs/pg-typed.md @@ -379,6 +379,29 @@ export async function getEmailsAlphabetical() { } ``` +### orderByAscDistinct(key) / orderByDescDistinct(key) + +Sort the records by the provided key and only return the first occurrence of each key. You can chain multiple calls to `orderByAscDistinct` or `orderByDescDistinct` with different keys to require more columns to have distinct value. + +```typescript +import db, {users} from './database'; + +export async function getLatestPostVersions() { + return await post_versions(db) + .find() + .orderByAscDistinct(`id`) + .orderByDesc(`version`) + .all(); +} +export async function getOldestPostVersions() { + return await post_versions(db) + .find() + .orderByAscDistinct(`id`) + .orderByAsc(`version`) + .all(); +} +``` + ### 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/pg-bulk/src/index.ts b/packages/pg-bulk/src/index.ts index 5c4676e6..b1764771 100644 --- a/packages/pg-bulk/src/index.ts +++ b/packages/pg-bulk/src/index.ts @@ -15,10 +15,14 @@ export interface BulkInsertOptions readonly records: readonly any[]; } -export interface BulkSelectOptions +export interface BulkConditionOptions extends BulkOperationOptions { readonly whereColumnNames: readonly TWhereColumn[]; readonly whereConditions: readonly any[]; +} +export interface BulkSelectOptions + extends BulkConditionOptions { + readonly distinctColumnNames?: readonly string[]; readonly selectColumnNames?: readonly string[]; readonly orderBy?: readonly { readonly columnName: string; @@ -37,10 +41,7 @@ export interface BulkUpdateOptions< } export interface BulkDeleteOptions - extends BulkOperationOptions { - readonly whereColumnNames: readonly TWhereColumn[]; - readonly whereConditions: readonly any[]; -} + extends BulkConditionOptions {} function tableId( options: BulkOperationOptions, @@ -51,7 +52,7 @@ function tableId( : sql.ident(options.tableName); } -function selectionSet( +function select( columns: readonly { readonly name: TColumnName; readonly alias?: ColumnName; @@ -62,7 +63,7 @@ function selectionSet( ) { const {database, columnTypes, serializeValue} = options; const {sql} = database; - return sql.join( + return sql`SELECT ${sql.join( columns.map(({name, alias, getValue}) => { const typeName = columnTypes[name]; if (!typeName) { @@ -74,39 +75,7 @@ function selectionSet( })}::${typeName}[])${alias ? sql` AS ${sql.ident(alias)}` : sql``}`; }), `,`, - ); -} - -function selection( - columns: readonly { - readonly name: TColumnName; - readonly alias?: ColumnName; - readonly getValue?: (record: any) => unknown; - }[], - records: readonly any[], - options: BulkOperationOptions, -) { - const {database} = options; - const {sql} = database; - return sql`(SELECT ${selectionSet(columns, records, options)}) AS bulk_query`; -} - -function condition( - columnNames: readonly TColumnName[], - options: BulkOperationOptions, -) { - const {database, tableName} = options; - const {sql} = database; - return sql.join( - columnNames.map( - (columnName) => - sql`${sql.ident(tableName, columnName)} = ${sql.ident( - `bulk_query`, - columnName, - )}`, - ), - ` AND `, - ); + )}`; } export async function bulkInsert( @@ -118,7 +87,7 @@ export async function bulkInsert( sql`INSERT INTO ${tableId(options)} (${sql.join( columnsToInsert.map((columnName) => sql.ident(columnName)), `,`, - )}) SELECT ${selectionSet( + )}) ${select( columnsToInsert.map((name) => ({name})), records, options, @@ -126,45 +95,51 @@ export async function bulkInsert( ); } +export function bulkCondition( + options: BulkConditionOptions, +): SQLQuery { + const {database, whereColumnNames, whereConditions} = options; + const {sql} = database; + return sql`(${sql.join( + whereColumnNames.map((columnName) => sql.ident(columnName)), + `,`, + )}) IN (${select( + whereColumnNames.map((columnName) => ({name: columnName})), + whereConditions, + options, + )})`; +} + export async function bulkSelect( options: BulkSelectOptions, ): Promise { - const { - database, - tableName, - whereColumnNames, - whereConditions, - selectColumnNames, - orderBy, - limit, - } = options; + const {database, distinctColumnNames, selectColumnNames, orderBy, limit} = + options; const {sql} = database; return await database.query( sql.join( [ - sql`SELECT ${ - selectColumnNames - ? sql.join( - selectColumnNames.map((columnName) => - sql.ident(tableName, columnName), - ), - ',', - ) - : sql`${sql.ident(tableName)}.*` - } FROM ${tableId(options)} INNER JOIN ${selection( - whereColumnNames.map((columnName) => ({ - name: columnName, - alias: columnName, - })), - whereConditions, - options, - )} ON (${condition(whereColumnNames, options)})`, + sql`SELECT`, + distinctColumnNames?.length + ? sql`DISTINCT ON (${sql.join( + distinctColumnNames.map((columnName) => sql.ident(columnName)), + `,`, + )})` + : null, + selectColumnNames + ? sql.join( + selectColumnNames.map((columnName) => sql.ident(columnName)), + ',', + ) + : sql`*`, + sql`FROM ${tableId(options)} WHERE`, + bulkCondition(options), orderBy?.length ? sql`ORDER BY ${sql.join( orderBy.map((q) => q.direction === 'ASC' - ? sql`${sql.ident(tableName, q.columnName)} ASC` - : sql`${sql.ident(tableName, q.columnName)} DESC`, + ? sql`${sql.ident(q.columnName)} ASC` + : sql`${sql.ident(q.columnName)} DESC`, ), sql`, `, )}` @@ -180,7 +155,8 @@ export async function bulkUpdate< TWhereColumn extends ColumnName, TSetColumn extends ColumnName, >(options: BulkUpdateOptions): Promise { - const {database, whereColumnNames, setColumnNames, updates} = options; + const {database, tableName, whereColumnNames, setColumnNames, updates} = + options; const {sql} = database; await database.query( sql`UPDATE ${tableId(options)} SET ${sql.join( @@ -192,7 +168,7 @@ export async function bulkUpdate< )}`, ), `,`, - )} FROM ${selection( + )} FROM (${select( [ ...whereColumnNames.map((columnName) => ({ name: columnName, @@ -207,23 +183,25 @@ export async function bulkUpdate< ], updates, options, - )} WHERE ${condition(whereColumnNames, options)}`, + )}) AS bulk_query WHERE ${sql.join( + whereColumnNames.map( + (columnName) => + sql`${sql.ident(tableName, columnName)} = ${sql.ident( + `bulk_query`, + columnName, + )}`, + ), + ` AND `, + )}`, ); } export async function bulkDelete( options: BulkDeleteOptions, ): Promise { - const {database, whereColumnNames, whereConditions} = options; + const {database} = options; const {sql} = database; await database.query( - sql`DELETE FROM ${tableId(options)} WHERE EXISTS (SELECT * FROM ${selection( - whereColumnNames.map((columnName) => ({ - name: columnName, - alias: columnName, - })), - whereConditions, - options, - )} WHERE ${condition(whereColumnNames, options)})`, + sql`DELETE FROM ${tableId(options)} WHERE ${bulkCondition(options)}`, ); } diff --git a/packages/pg-typed/src/index.ts b/packages/pg-typed/src/index.ts index f33ce410..6fd6d0c1 100644 --- a/packages/pg-typed/src/index.ts +++ b/packages/pg-typed/src/index.ts @@ -1,10 +1,10 @@ import {SQLQuery, Queryable} from '@databases/pg'; import { bulkInsert, - bulkSelect, bulkUpdate, bulkDelete, BulkOperationOptions, + bulkCondition, } from '@databases/pg-bulk'; const NO_RESULT_FOUND = `NO_RESULT_FOUND`; @@ -56,6 +56,19 @@ export interface SelectQuery { ): SelectQuery>; } +export interface UnorderedSelectQuery extends SelectQuery { + orderByAscDistinct(key: keyof TRecord): DistinctOrderedSelectQuery; + orderByDescDistinct(key: keyof TRecord): DistinctOrderedSelectQuery; +} + +export interface DistinctOrderedSelectQuery + extends SelectQuery { + orderByAscDistinct(key: keyof TRecord): DistinctOrderedSelectQuery; + orderByDescDistinct(key: keyof TRecord): DistinctOrderedSelectQuery; + first(): Promise; + limit(count: number): Promise; +} + export interface OrderedSelectQuery extends SelectQuery { first(): Promise; limit(count: number): Promise; @@ -101,7 +114,7 @@ class FieldQuery { export type {FieldQuery}; export type WhereCondition = Partial< - {[key in keyof TRecord]: TRecord[key] | FieldQuery} + {readonly [key in keyof TRecord]: TRecord[key] | FieldQuery} >; export function anyOf(values: { @@ -180,6 +193,7 @@ export function greaterThan(value: T) { interface SelectQueryOptions { selectColumnNames: readonly string[] | undefined; + distinctColumnNames: readonly string[]; orderBy: readonly { readonly columnName: string; readonly direction: 'ASC' | 'DESC'; @@ -187,8 +201,9 @@ interface SelectQueryOptions { limit: number | undefined; } class SelectQueryImplementation - implements OrderedSelectQuery + implements DistinctOrderedSelectQuery { + private readonly _distinctColumnNames: string[] = []; private readonly _orderByQueries: { columnName: string; direction: 'ASC' | 'DESC'; @@ -216,9 +231,36 @@ class SelectQueryImplementation selectColumnNames: this._selectFields, orderBy: this._orderByQueries, limit: this._limitCount, + distinctColumnNames: this._distinctColumnNames, }); } + public orderByAscDistinct( + columnName: keyof TRecord, + ): DistinctOrderedSelectQuery { + if (this._distinctColumnNames.length !== this._orderByQueries.length) { + throw new Error(`Cannot add distinct field after adding order by field`); + } + this._distinctColumnNames.push(columnName as string); + this._orderByQueries.push({ + columnName: columnName as string, + direction: `ASC`, + }); + return this; + } + public orderByDescDistinct( + columnName: keyof TRecord, + ): DistinctOrderedSelectQuery { + if (this._distinctColumnNames.length !== this._orderByQueries.length) { + throw new Error(`Cannot add distinct field after adding order by field`); + } + this._distinctColumnNames.push(columnName as string); + this._orderByQueries.push({ + columnName: columnName as string, + direction: `DESC`, + }); + return this; + } public orderByAsc(columnName: keyof TRecord): OrderedSelectQuery { this._orderByQueries.push({ columnName: columnName as string, @@ -294,10 +336,10 @@ class SelectQueryImplementation } type BulkRecord = { - [key in TKey]-?: Exclude; + readonly [key in TKey]-?: Exclude; } & { - [key in Exclude]?: undefined; + readonly [key in Exclude]?: undefined; }; type BulkInsertFields< @@ -306,7 +348,7 @@ type BulkInsertFields< > = | TKey | { - [K in keyof TInsertParameters]: undefined extends TInsertParameters[K] + readonly [K in keyof TInsertParameters]: undefined extends TInsertParameters[K] ? never : K; }[keyof TInsertParameters]; @@ -316,7 +358,7 @@ type BulkInsertRecord< TKey extends keyof TInsertParameters, > = BulkRecord>; -function rowToOptionalWhere( +function rowToCondition( row: WhereCondition, sql: Queryable['sql'], toValue: (columnName: string, value: unknown) => unknown, @@ -325,19 +367,20 @@ function rowToOptionalWhere( if (entries.length === 0) { return null; } - return sql`WHERE ${sql.join( + return sql.join( entries.map(([columnName, value]) => FieldQuery.query(columnName, value, sql, toValue), ), sql` AND `, - )}`; + ); } function rowToWhere( row: WhereCondition, sql: Queryable['sql'], toValue: (columnName: string, value: unknown) => unknown, ): SQLQuery { - return rowToOptionalWhere(row, sql, toValue) ?? sql``; + const condition = rowToCondition(row, sql, toValue); + return condition ? sql`WHERE ${condition}` : sql``; } type BulkOperationOptionsBase< @@ -408,7 +451,9 @@ class Table { } async bulkInsert< - TColumnsToInsert extends [...(readonly (keyof TInsertParameters)[])], + TColumnsToInsert extends readonly [ + ...(readonly (keyof TInsertParameters)[]) + ], >({ columnsToInsert, records, @@ -431,7 +476,7 @@ class Table { }); } - bulkFind({ + bulkFind({ whereColumnNames, whereConditions, }: { @@ -440,26 +485,20 @@ class Table { TRecord, TWhereColumns[number] >[]; - }): SelectQuery { + }): UnorderedSelectQuery { const bulkOperationOptions = this._getBulkOperationOptions(); - return new SelectQueryImplementation( - this.tableName, - async ({selectColumnNames, orderBy, limit}) => { - return bulkSelect({ - ...bulkOperationOptions, - whereColumnNames, - whereConditions, - selectColumnNames, - orderBy, - limit, - }); - }, + return this.findUntyped( + bulkCondition({ + ...bulkOperationOptions, + whereColumnNames, + whereConditions, + }), ); } async bulkUpdate< - TWhereColumns extends [...(readonly (keyof TRecord)[])], - TSetColumns extends [...(readonly (keyof TRecord)[])], + TWhereColumns extends readonly [...(readonly (keyof TRecord)[])], + TSetColumns extends readonly [...(readonly (keyof TRecord)[])], >({ whereColumnNames, setColumnNames, @@ -480,7 +519,9 @@ class Table { }); } - async bulkDelete({ + async bulkDelete< + TWhereColumns extends readonly [...(readonly (keyof TRecord)[])], + >({ whereColumnNames, whereConditions, }: { @@ -546,9 +587,11 @@ class Table { async insert( ...rows: keyof TRecordsToInsert[number] extends keyof TInsertParameters ? TRecordsToInsert - : readonly ({[key in keyof TInsertParameters]: TInsertParameters[key]} & + : readonly ({ + readonly [key in keyof TInsertParameters]: TInsertParameters[key]; + } & { - [key in Exclude< + readonly [key in Exclude< keyof TRecordsToInsert[number], keyof TInsertParameters >]: never; @@ -562,7 +605,7 @@ class Table { } async insertOrUpdate( - conflictKeys: [keyof TRecord, ...(keyof TRecord)[]], + conflictKeys: readonly [keyof TRecord, ...(keyof TRecord)[]], ...rows: keyof TRecordsToInsert[number] extends keyof TInsertParameters ? TRecordsToInsert : readonly ({[key in keyof TInsertParameters]: TInsertParameters[key]} & @@ -592,9 +635,11 @@ class Table { async insertOrIgnore( ...rows: keyof TRecordsToInsert[number] extends keyof TInsertParameters ? TRecordsToInsert - : readonly ({[key in keyof TInsertParameters]: TInsertParameters[key]} & + : readonly ({ + readonly [key in keyof TInsertParameters]: TInsertParameters[key]; + } & { - [key in Exclude< + readonly [key in Exclude< keyof TRecordsToInsert[number], keyof TInsertParameters >]: never; @@ -633,10 +678,13 @@ class Table { /** * @deprecated use .find instead of .select */ - select(whereValues: WhereCondition = {}): SelectQuery { + select( + whereValues: WhereCondition = {}, + ): UnorderedSelectQuery { return this.find(whereValues); } - find(whereValues: WhereCondition = {}): SelectQuery { + + findUntyped(whereCondition: SQLQuery | null): UnorderedSelectQuery { const {sql} = this._underlyingDb; return new SelectQueryImplementation( this.tableName, @@ -644,19 +692,26 @@ class Table { selectColumnNames: selectFields, orderBy: orderByQueries, limit: limitCount, + distinctColumnNames, }) => { return await this._underlyingDb.query( sql.join( [ - sql`SELECT ${ - selectFields - ? sql.join( - selectFields.map((f) => sql.ident(f)), - ',', - ) - : sql`*` - } FROM ${this.tableId}`, - rowToOptionalWhere(whereValues, sql, this._value), + sql`SELECT`, + distinctColumnNames.length + ? sql`DISTINCT ON (${sql.join( + distinctColumnNames.map((f) => sql.ident(f)), + `,`, + )})` + : null, + selectFields + ? sql.join( + selectFields.map((f) => sql.ident(f)), + ',', + ) + : sql`*`, + sql`FROM ${this.tableId}`, + whereCondition ? sql`WHERE ${whereCondition}` : null, orderByQueries.length ? sql`ORDER BY ${sql.join( orderByQueries.map((q) => @@ -676,6 +731,13 @@ class Table { ); } + find( + whereValues: WhereCondition = {}, + ): UnorderedSelectQuery { + const {sql} = this._underlyingDb; + return this.findUntyped(rowToCondition(whereValues, sql, this._value)); + } + /** * @deprecated use .findOne instead of .selectOne */