From 8ca3cc1fa1c923d08ce756cd5590d97139b92fe5 Mon Sep 17 00:00:00 2001 From: Greg Richardson Date: Wed, 8 May 2024 15:53:06 -0600 Subject: [PATCH] feat(sql-to-rest): group by --- packages/sql-to-rest/src/processor.ts | 75 +++++++++++++++++++ .../sql-to-rest/src/renderers/http.test.ts | 59 +++++++++++++++ packages/sql-to-rest/src/types/libpg-query.ts | 2 +- packages/ui-patterns/SqlToRest/assumptions.ts | 4 +- packages/ui-patterns/SqlToRest/faqs.ts | 3 +- packages/ui-patterns/SqlToRest/util.ts | 58 +------------- 6 files changed, 140 insertions(+), 61 deletions(-) diff --git a/packages/sql-to-rest/src/processor.ts b/packages/sql-to-rest/src/processor.ts index dba3e8c6335..de2d27af838 100644 --- a/packages/sql-to-rest/src/processor.ts +++ b/packages/sql-to-rest/src/processor.ts @@ -226,12 +226,34 @@ function processSelectStmt(stmt: SelectStmt): Select { throw new UnsupportedError('CTEs are not supported') } + if (stmt.SelectStmt.havingClause) { + throw new UnsupportedError('The HAVING clause is not supported') + } + const [fromClause] = stmt.SelectStmt.fromClause const { from, alias, embeddedTargets } = processFromClause(fromClause) const targets = processTargetList(stmt.SelectStmt.targetList, alias ?? from, embeddedTargets) + const groupByColumns = + stmt.SelectStmt.groupClause?.map((columnRef) => renderFields(columnRef.ColumnRef.fields)) ?? [] + + if ( + !groupByColumns.every((column) => someTarget(targets, (target) => target.column === column)) + ) { + throw new Error(`Every group by column must also exist as a select target`) + } + + if ( + groupByColumns.length > 0 && + !someTarget(targets, (target) => target.type === 'aggregate-target') + ) { + throw new Error( + `There must be at least one aggregate function in the select target list when using group by` + ) + } + const filter = stmt.SelectStmt.whereClause ? processWhereClause(stmt.SelectStmt.whereClause) : undefined @@ -934,3 +956,56 @@ function processJsonTarget(expression: A_Expr): ColumnTarget { cast, } } + +/** + * Recursively iterates through PostgREST filters and checks if the predicate + * matches any of them (ie. `some()`). + */ +export function someFilter(filter: Filter, predicate: (filter: ColumnFilter) => boolean): boolean { + const { type } = filter + + if (type === 'column') { + return predicate(filter) + } else if (type === 'logical') { + return filter.values.some((f) => someFilter(f, predicate)) + } else { + throw new Error(`Unknown filter type '${type}'`) + } +} + +/** + * Recursively iterates through a PostgREST target list and checks if the predicate + * matches any of them (ie. `some()`). + */ +export function someTarget( + targets: Target[], + predicate: (target: ColumnTarget | AggregateTarget) => boolean +): boolean { + return targets.some((target) => { + const { type } = target + + if (type === 'column-target' || type === 'aggregate-target') { + return predicate(target) + } else if (type === 'embedded-target') { + return someTarget(target.targets, predicate) + } else { + throw new Error(`Unknown target type '${type}'`) + } + }) +} + +/** + * Recursively flattens PostgREST embedded targets. + */ +export function flattenTargets(targets: Target[]): Target[] { + return targets.flatMap((target) => { + const { type } = target + if (type === 'column-target' || type === 'aggregate-target') { + return target + } else if (type === 'embedded-target') { + return [target, ...flattenTargets(target.targets)] + } else { + throw new Error(`Unknown target type '${type}'`) + } + }) +} diff --git a/packages/sql-to-rest/src/renderers/http.test.ts b/packages/sql-to-rest/src/renderers/http.test.ts index 41c194fb3df..660abce92ef 100644 --- a/packages/sql-to-rest/src/renderers/http.test.ts +++ b/packages/sql-to-rest/src/renderers/http.test.ts @@ -1149,4 +1149,63 @@ describe('select', () => { expect(method).toBe('GET') expect(fullPath).toBe('/orders?select=order_details->tax_amount::numeric.sum()') }) + + test('group by aggregates', async () => { + const sql = stripIndents` + select + sum(amount), + category + from + orders + group by + category + ` + + const statement = await processSql(sql) + const { method, fullPath } = await renderHttp(statement) + + expect(method).toBe('GET') + expect(fullPath).toBe('/orders?select=amount.sum(),category') + }) + + test('group by without select target fails', async () => { + const sql = stripIndents` + select + sum(amount) + from + orders + group by + category + ` + + await expect(processSql(sql)).rejects.toThrowError() + }) + + test('group by with having fails', async () => { + const sql = stripIndents` + select + sum(amount), + category + from + orders + group by + category + having sum(amount) > 1000 + ` + + await expect(processSql(sql)).rejects.toThrowError() + }) + + test('group by without aggregate fails', async () => { + const sql = stripIndents` + select + category + from + orders + group by + category + ` + + await expect(processSql(sql)).rejects.toThrowError() + }) }) diff --git a/packages/sql-to-rest/src/types/libpg-query.ts b/packages/sql-to-rest/src/types/libpg-query.ts index 922a455ed19..60376f2b33f 100644 --- a/packages/sql-to-rest/src/types/libpg-query.ts +++ b/packages/sql-to-rest/src/types/libpg-query.ts @@ -195,7 +195,7 @@ export type SelectStmt = { distinctClause?: any[] intoClause?: any[] whereClause?: WhereExpression - groupClause?: any[] + groupClause?: ColumnRef[] groupDistinct?: boolean havingClause?: any[] withClause?: any diff --git a/packages/ui-patterns/SqlToRest/assumptions.ts b/packages/ui-patterns/SqlToRest/assumptions.ts index e94e8b2d40a..324dd721cd3 100644 --- a/packages/ui-patterns/SqlToRest/assumptions.ts +++ b/packages/ui-patterns/SqlToRest/assumptions.ts @@ -1,5 +1,5 @@ -import { EmbeddedTarget } from 'sql-to-rest' -import { ResultBundle, flattenTargets } from './util' +import { EmbeddedTarget, flattenTargets } from 'sql-to-rest' +import { ResultBundle } from './util' export type Assumption = { id: string diff --git a/packages/ui-patterns/SqlToRest/faqs.ts b/packages/ui-patterns/SqlToRest/faqs.ts index f735927ad32..d21a9e570f4 100644 --- a/packages/ui-patterns/SqlToRest/faqs.ts +++ b/packages/ui-patterns/SqlToRest/faqs.ts @@ -1,5 +1,6 @@ import { stripIndent } from 'common-tags' -import { ResultBundle, someFilter } from './util' +import { someFilter } from 'sql-to-rest' +import { ResultBundle } from './util' export type Faq = { id: string diff --git a/packages/ui-patterns/SqlToRest/util.ts b/packages/ui-patterns/SqlToRest/util.ts index 47f1b043cfc..7335e7f561e 100644 --- a/packages/ui-patterns/SqlToRest/util.ts +++ b/packages/ui-patterns/SqlToRest/util.ts @@ -1,12 +1,4 @@ -import { - ColumnFilter, - ColumnTarget, - Filter, - HttpRequest, - Statement, - SupabaseJsQuery, - Target, -} from 'sql-to-rest' +import { HttpRequest, Statement, SupabaseJsQuery } from 'sql-to-rest' export type BaseResult = { statement: Statement @@ -25,51 +17,3 @@ export type SupabaseJsResult = BaseResult & } export type ResultBundle = HttpResult | SupabaseJsResult - -/** - * Recursively iterates through PostgREST filters and checks if the predicate - * matches any of them (ie. `some()`). - */ -export function someFilter(filter: Filter, predicate: (filter: ColumnFilter) => boolean): boolean { - const { type } = filter - - if (type === 'column') { - return predicate(filter) - } else if (type === 'logical') { - return filter.values.some((f) => someFilter(f, predicate)) - } else { - throw new Error(`Unknown filter type '${type}'`) - } -} - -/** - * Recursively iterates through a PostgREST target list and checks if the predicate - * matches any of them (ie. `some()`). - */ -export function someTarget(target: Target, predicate: (target: ColumnTarget) => boolean): boolean { - const { type } = target - - if (type === 'column-target') { - return predicate(target) - } else if (type === 'embedded-target') { - return target.targets.some((t) => someTarget(t, predicate)) - } else { - throw new Error(`Unknown target type '${type}'`) - } -} - -/** - * Recursively flattens PostgREST embedded targets. - */ -export function flattenTargets(targets: Target[]): Target[] { - return targets.flatMap((target) => { - const { type } = target - if (type === 'column-target') { - return target - } else if (type === 'embedded-target') { - return [target, ...flattenTargets(target.targets)] - } else { - throw new Error(`Unknown target type '${type}'`) - } - }) -}