chore(studio): move Query to pgMeta add tests (#34232)
* chore(studio): move Query to pgMeta add tests - Move the Query builder from studio to pgMeta - Add e2e tests over the generated sql to ensure syntax and runtime result over pg database - fix bug with orde by for table with undefined column * chore: fix query import path * chore: set ES target for lint * chore: add github action for pg-meta test package * chore: add tsconfig to sparse checkout
This commit is contained in:
50
.github/workflows/pg-meta-tests.yml
vendored
Normal file
50
.github/workflows/pg-meta-tests.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: PG Meta Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['master']
|
||||
paths:
|
||||
- 'packages/pg-meta/**/*'
|
||||
pull_request:
|
||||
branches: ['master']
|
||||
paths:
|
||||
- 'packages/pg-meta/**/*'
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
packages/pg-meta
|
||||
packages/tsconfig
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm i
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm --filter=@supabase/pg-meta run test
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
directory: packages/pg-meta/coverage
|
||||
flags: pg-meta
|
||||
2
.github/workflows/studio-e2e-tests.yml
vendored
2
.github/workflows/studio-e2e-tests.yml
vendored
@@ -3,12 +3,14 @@ on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/pg-meta/**/*'
|
||||
- 'apps/studio/**'
|
||||
- 'tests/studio-tests/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/pg-meta/**/*'
|
||||
- 'apps/studio/**'
|
||||
- 'tests/studio-tests/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
import { Filter, Sort } from '@supabase/pg-meta/src/query'
|
||||
import { CalculatedColumn, RenderHeaderCellProps } from 'react-data-grid'
|
||||
|
||||
export interface Sort {
|
||||
table: string
|
||||
column: string
|
||||
ascending?: boolean
|
||||
nullsFirst?: boolean
|
||||
}
|
||||
|
||||
export type FilterOperator =
|
||||
| '='
|
||||
| '<>'
|
||||
| '>'
|
||||
| '<'
|
||||
| '>='
|
||||
| '<='
|
||||
| '~~'
|
||||
| '~~*'
|
||||
| '!~~'
|
||||
| '!~~*'
|
||||
| 'in'
|
||||
| 'is'
|
||||
|
||||
export interface Filter {
|
||||
column: string
|
||||
operator: FilterOperator
|
||||
value: any
|
||||
}
|
||||
|
||||
export interface SavedState {
|
||||
filters: Filter[]
|
||||
sorts: Sort[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type * from '@supabase/pg-meta/src/query'
|
||||
export type * from './base'
|
||||
export type * from './grid'
|
||||
export type * from './query'
|
||||
export type * from './service'
|
||||
export type * from './table'
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface QueryTable {
|
||||
name: string
|
||||
schema: string
|
||||
}
|
||||
|
||||
export interface QueryPagination {
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { chunk, find, isEmpty, isEqual } from 'lodash'
|
||||
import Papa from 'papaparse'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import SparkBar from 'components/ui/SparkBar'
|
||||
import { createDatabaseColumn } from 'data/database-columns/database-column-create-mutation'
|
||||
import { deleteDatabaseColumn } from 'data/database-columns/database-column-delete-mutation'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
import { pgSodiumKeys } from './keys'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { sortBy } from 'lodash'
|
||||
import { executeSql } from '../sql/execute-sql-query'
|
||||
import { pgSodiumKeys } from './keys'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { ImpersonationRole, wrapWithRoleImpersonation } from 'lib/role-impersonation'
|
||||
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import type { Filter, SupaTable } from 'components/grid/types'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { ImpersonationRole, wrapWithRoleImpersonation } from 'lib/role-impersonation'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import type { SupaRow } from 'components/grid/types'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
import { tableRowKeys } from './keys'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { ImpersonationRole, wrapWithRoleImpersonation } from 'lib/role-impersonation'
|
||||
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-query'
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
|
||||
import type { Filter, SupaTable } from 'components/grid/types'
|
||||
import { prefetchTableEditor } from 'data/table-editor/table-editor-query'
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
|
||||
import { Filter, Sort, SupaRow, SupaTable } from 'components/grid/types'
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { executeSql } from '../sql/execute-sql-query'
|
||||
import { vaultSecretsKeys } from './keys'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
import { vaultSecretsKeys } from './keys'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import type { VaultSecret } from 'types'
|
||||
import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
|
||||
import { vaultSecretsKeys } from './keys'
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"db:clean": "cd test/db && docker compose down",
|
||||
"db:run": "cd test/db && docker compose up --detach --wait",
|
||||
"test:run": "vitest run --coverage",
|
||||
"test:update": "vitest run --update"
|
||||
"test:update": "vitest run --update",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -16,6 +16,7 @@ import types from './pg-meta-types'
|
||||
import version from './pg-meta-version'
|
||||
import indexes from './pg-meta-indexes'
|
||||
import columnPrivileges from './pg-meta-column-privileges'
|
||||
import * as query from './query/index'
|
||||
|
||||
export default {
|
||||
roles,
|
||||
@@ -36,4 +37,5 @@ export default {
|
||||
version,
|
||||
indexes,
|
||||
columnPrivileges,
|
||||
query,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { format, ident, literal } from '@supabase/pg-meta/src/pg-format'
|
||||
import type { Dictionary } from 'types'
|
||||
import type { Filter, QueryPagination, QueryTable, Sort } from '../types'
|
||||
import { ident, literal, format } from '../pg-format'
|
||||
import type { Filter, QueryPagination, QueryTable, Sort, Dictionary } from './types'
|
||||
|
||||
export function countQuery(
|
||||
table: QueryTable,
|
||||
@@ -51,7 +50,7 @@ export function deleteQuery(
|
||||
query +=
|
||||
enumArrayColumns === undefined || enumArrayColumns.length === 0
|
||||
? ` returning *`
|
||||
: ` returning *, ${enumArrayColumns.map((x) => `"${x}"::text[]`).join(',')}`
|
||||
: ` returning *, ${enumArrayColumns.map((x) => `${ident(x)}::text[]`).join(',')}`
|
||||
}
|
||||
return query + ';'
|
||||
}
|
||||
@@ -90,7 +89,7 @@ export function insertQuery(
|
||||
query +=
|
||||
enumArrayColumns === undefined || enumArrayColumns.length === 0
|
||||
? ` returning *`
|
||||
: ` returning *, ${enumArrayColumns.map((x) => `"${x}"::text[]`).join(',')}`
|
||||
: ` returning *, ${enumArrayColumns.map((x) => `${ident(x)}::text[]`).join(',')}`
|
||||
}
|
||||
return query + ';'
|
||||
}
|
||||
@@ -151,7 +150,7 @@ export function updateQuery(
|
||||
query +=
|
||||
enumArrayColumns === undefined || enumArrayColumns.length === 0
|
||||
? ` returning *`
|
||||
: ` returning *, ${enumArrayColumns.map((x) => `"${x}"::text[]`).join(',')}`
|
||||
: ` returning *, ${enumArrayColumns.map((x) => `${ident(x)}::text[]`).join(',')}`
|
||||
}
|
||||
|
||||
return query + ';'
|
||||
@@ -218,10 +217,10 @@ function filterLiteral(value: any) {
|
||||
//============================================================
|
||||
|
||||
function applySorts(query: string, sorts: Sort[]) {
|
||||
if (sorts.length === 0) return query
|
||||
query += ` order by ${sorts
|
||||
const validSorts = sorts.filter((sort) => sort.column)
|
||||
if (validSorts.length === 0) return query
|
||||
query += ` order by ${validSorts
|
||||
.map((x) => {
|
||||
if (!x.column) return null
|
||||
const order = x.ascending ? 'asc' : 'desc'
|
||||
const nullOrder = x.nullsFirst ? 'nulls first' : 'nulls last'
|
||||
return `${ident(x.table)}.${ident(x.column)} ${order} ${nullOrder}`
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { QueryTable } from '../types'
|
||||
import type { Dictionary } from 'types'
|
||||
import type { QueryTable, Dictionary } from './types'
|
||||
import { IQueryFilter, QueryFilter } from './QueryFilter'
|
||||
|
||||
export interface IQueryAction {
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Dictionary } from 'types'
|
||||
import type { Filter, FilterOperator, QueryTable, Sort } from '../types'
|
||||
import type { Filter, FilterOperator, QueryTable, Sort, Dictionary } from './types'
|
||||
import { IQueryModifier, QueryModifier } from './QueryModifier'
|
||||
|
||||
export interface IQueryFilter {
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Dictionary } from 'types'
|
||||
import type { Filter, QueryPagination, QueryTable, Sort } from '../types'
|
||||
import type { Filter, QueryPagination, QueryTable, Sort, Dictionary } from './types'
|
||||
import {
|
||||
countQuery,
|
||||
deleteQuery,
|
||||
6
packages/pg-meta/src/query/index.ts
Normal file
6
packages/pg-meta/src/query/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './Query'
|
||||
export * from './Query.utils'
|
||||
export * from './QueryFilter'
|
||||
export * from './QueryAction'
|
||||
export * from './QueryModifier'
|
||||
export type * from './types'
|
||||
40
packages/pg-meta/src/query/types.ts
Normal file
40
packages/pg-meta/src/query/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface Sort {
|
||||
table: string
|
||||
column: string
|
||||
ascending?: boolean
|
||||
nullsFirst?: boolean
|
||||
}
|
||||
|
||||
export type FilterOperator =
|
||||
| '='
|
||||
| '<>'
|
||||
| '>'
|
||||
| '<'
|
||||
| '>='
|
||||
| '<='
|
||||
| '~~'
|
||||
| '~~*'
|
||||
| '!~~'
|
||||
| '!~~*'
|
||||
| 'in'
|
||||
| 'is'
|
||||
|
||||
export interface Filter {
|
||||
column: string
|
||||
operator: FilterOperator
|
||||
value: any
|
||||
}
|
||||
|
||||
export interface Dictionary<T> {
|
||||
[Key: string]: T
|
||||
}
|
||||
|
||||
export interface QueryTable {
|
||||
name: string
|
||||
schema: string
|
||||
}
|
||||
|
||||
export interface QueryPagination {
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
587
packages/pg-meta/test/query/advanced-query.test.ts
Normal file
587
packages/pg-meta/test/query/advanced-query.test.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
import { expect, test, describe, afterAll } from 'vitest'
|
||||
import { Query } from '../../src/query/Query'
|
||||
import { createTestDatabase, cleanupRoot } from '../db/utils'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestDatabase>>
|
||||
|
||||
async function validateSql(db: TestDb, sql: string): Promise<any> {
|
||||
try {
|
||||
const result = await db.executeQuery(sql)
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid SQL generated: ${sql}\nError: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const withTestDatabase = (name: string, fn: (db: TestDb) => Promise<void>) => {
|
||||
test(name, async () => {
|
||||
const db = await createTestDatabase()
|
||||
try {
|
||||
// Setup test tables with special characters, spaces, and quotes
|
||||
await db.executeQuery(`
|
||||
CREATE TABLE "public"."normal_table" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE "public"."table with spaces" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
"column with spaces" TEXT,
|
||||
"quoted""column" TEXT,
|
||||
"quoted'column" TEXT,
|
||||
"camelCaseColumn" TEXT,
|
||||
"special#$%^&Column" TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE "public"."quoted""table" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE "public"."quoted'table" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE "public"."camelCaseTable" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE "public"."special#$%^&Table" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT
|
||||
);
|
||||
`)
|
||||
|
||||
// Insert test data into each table
|
||||
await db.executeQuery(`
|
||||
-- Add data to normal_table
|
||||
INSERT INTO "public"."normal_table" (name)
|
||||
VALUES
|
||||
('John Doe'),
|
||||
('Jane Smith'),
|
||||
('O''Reilly Books'),
|
||||
(NULL);
|
||||
|
||||
-- Add data to table with spaces
|
||||
INSERT INTO "public"."table with spaces" (
|
||||
"column with spaces",
|
||||
"quoted""column",
|
||||
"quoted'column",
|
||||
"camelCaseColumn",
|
||||
"special#$%^&Column"
|
||||
)
|
||||
VALUES
|
||||
('value with spaces', 'value with "quotes"', 'value with ''quotes''', 'camelCaseValue', 'special#$%^&Value'),
|
||||
('another value', 'another "quoted" value', 'another ''quoted'' value', 'anotherCamelCase', 'another#$%^&');
|
||||
|
||||
-- Add data to quoted"table
|
||||
INSERT INTO "public"."quoted""table" (name)
|
||||
VALUES
|
||||
('quoted table row 1'),
|
||||
('quoted table row 2');
|
||||
|
||||
-- Add data to quoted'table
|
||||
INSERT INTO "public"."quoted'table" (name)
|
||||
VALUES
|
||||
('single quoted table row 1'),
|
||||
('single quoted table row 2');
|
||||
|
||||
-- Add data to camelCaseTable
|
||||
INSERT INTO "public"."camelCaseTable" (name)
|
||||
VALUES
|
||||
('camel case table row 1'),
|
||||
('camel case table row 2');
|
||||
|
||||
-- Add data to special#$%^&Table
|
||||
INSERT INTO "public"."special#$%^&Table" (name)
|
||||
VALUES
|
||||
('special char table row 1'),
|
||||
('special char table row 2');
|
||||
`)
|
||||
|
||||
await fn(db)
|
||||
} finally {
|
||||
await db.cleanup()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Advanced Query Tests', () => {
|
||||
afterAll(async () => {
|
||||
await cleanupRoot()
|
||||
})
|
||||
|
||||
describe('Special Table and Column Names', () => {
|
||||
withTestDatabase('should handle tables with spaces', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('table with spaces', 'public').select('*').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot('"select * from public."table with spaces";"')
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]['column with spaces']).toBe('value with spaces')
|
||||
expect(result[1]['column with spaces']).toBe('another value')
|
||||
})
|
||||
|
||||
withTestDatabase('should handle tables with double quotes', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('quoted"table', 'public').select('*').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot('"select * from public."quoted""table";"')
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0].name).toBe('quoted table row 1')
|
||||
expect(result[1].name).toBe('quoted table row 2')
|
||||
})
|
||||
|
||||
withTestDatabase('should handle tables with single quotes', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from("quoted'table", 'public').select('*').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot('"select * from public."quoted\'table";"')
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0].name).toBe('single quoted table row 1')
|
||||
expect(result[1].name).toBe('single quoted table row 2')
|
||||
})
|
||||
|
||||
withTestDatabase('should handle camelCase table names', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('camelCaseTable', 'public').select('*').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot('"select * from public."camelCaseTable";"')
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0].name).toBe('camel case table row 1')
|
||||
expect(result[1].name).toBe('camel case table row 2')
|
||||
})
|
||||
|
||||
withTestDatabase('should handle tables with special characters', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('special#$%^&Table', 'public').select('*').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot('"select * from public."special#$%^&Table";"')
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0].name).toBe('special char table row 1')
|
||||
expect(result[1].name).toBe('special char table row 2')
|
||||
})
|
||||
|
||||
withTestDatabase('should handle columns with spaces', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('table with spaces', 'public').select('"column with spaces"').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select "column with spaces" from public."table with spaces";"'
|
||||
)
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]['column with spaces']).toBe('value with spaces')
|
||||
expect(result[1]['column with spaces']).toBe('another value')
|
||||
})
|
||||
|
||||
withTestDatabase('should handle columns with double quotes', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('table with spaces', 'public').select('"quoted""column"').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select "quoted""column" from public."table with spaces";"'
|
||||
)
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]['quoted"column']).toBe('value with "quotes"')
|
||||
expect(result[1]['quoted"column']).toBe('another "quoted" value')
|
||||
})
|
||||
|
||||
withTestDatabase('should handle columns with single quotes', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('table with spaces', 'public').select('"quoted\'column"').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select "quoted\'column" from public."table with spaces";"'
|
||||
)
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]["quoted'column"]).toBe("value with 'quotes'")
|
||||
expect(result[1]["quoted'column"]).toBe("another 'quoted' value")
|
||||
})
|
||||
|
||||
withTestDatabase('should handle camelCase column names', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('table with spaces', 'public').select('"camelCaseColumn"').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select "camelCaseColumn" from public."table with spaces";"'
|
||||
)
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0].camelCaseColumn).toBe('camelCaseValue')
|
||||
expect(result[1].camelCaseColumn).toBe('anotherCamelCase')
|
||||
})
|
||||
|
||||
withTestDatabase('should handle columns with special characters', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('table with spaces', 'public').select('"special#$%^&Column"').toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select "special#$%^&Column" from public."table with spaces";"'
|
||||
)
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]['special#$%^&Column']).toBe('special#$%^&Value')
|
||||
expect(result[1]['special#$%^&Column']).toBe('another#$%^&')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Complex Queries with Special Names', () => {
|
||||
withTestDatabase('should handle filtering on columns with spaces', async (db) => {
|
||||
// First ensure the table exists with the right column
|
||||
await db.executeQuery(`
|
||||
DROP TABLE IF EXISTS "public"."table with spaces";
|
||||
CREATE TABLE "public"."table with spaces" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
"column with spaces" TEXT
|
||||
);
|
||||
|
||||
-- Insert test data
|
||||
INSERT INTO "public"."table with spaces" ("column with spaces")
|
||||
VALUES ('test value'), ('other value');
|
||||
`)
|
||||
|
||||
const query = new Query()
|
||||
|
||||
// Specify the column name without extra quotes in the filter
|
||||
// The Query class handles the proper quoting
|
||||
const sql = query
|
||||
.from('table with spaces', 'public')
|
||||
.select('*')
|
||||
.filter('column with spaces', '=', 'test value')
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select * from public."table with spaces" where "column with spaces" = \'test value\';"'
|
||||
)
|
||||
|
||||
// Validate the generated SQL directly against the database
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]['column with spaces']).toBe('test value')
|
||||
})
|
||||
|
||||
withTestDatabase('should handle filtering with values containing quotes', async (db) => {
|
||||
await db.executeQuery(`
|
||||
INSERT INTO "public"."normal_table" (name)
|
||||
VALUES ('O''Reilly');
|
||||
`)
|
||||
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.select('*')
|
||||
.filter('name', '=', "O'Reilly")
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
"\"select * from public.normal_table where name = 'O''Reilly';\""
|
||||
)
|
||||
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].name).toBe("O'Reilly")
|
||||
})
|
||||
|
||||
withTestDatabase('should handle updating with values containing quotes', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.update({ name: "John O'Reilly" }, { returning: true })
|
||||
.filter('id', '=', 1)
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
`"update public.normal_table set (name) = (select name from json_populate_record(null::public.normal_table, '{"name":"John O''Reilly"}')) where id = 1 returning *;"`
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
withTestDatabase('should handle inserting with values containing quotes', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.insert([{ name: "John O'Reilly" }], { returning: true })
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
`"insert into public.normal_table (name) select name from jsonb_populate_recordset(null::public.normal_table, '[{"name":"John O''Reilly"}]') returning *;"`
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Advanced SQL Generation and Validation', () => {
|
||||
withTestDatabase(
|
||||
'should generate valid select with multiple filters and sorting',
|
||||
async (db) => {
|
||||
await db.executeQuery(`
|
||||
DELETE FROM "public"."normal_table";
|
||||
INSERT INTO "public"."normal_table" (id, name)
|
||||
VALUES
|
||||
(11, 'John Smith'),
|
||||
(12, 'John Doe'),
|
||||
(13, 'Jane Smith'),
|
||||
(14, 'Someone Else');
|
||||
`)
|
||||
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.select('id, name')
|
||||
.filter('id', '>', 10)
|
||||
.filter('name', '~~', '%John%')
|
||||
.order('normal_table', 'name', true, false)
|
||||
.range(0, 9)
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select id, name from public.normal_table where id > 10 and name ~~ \'%John%\' order by normal_table.name asc nulls last limit 10 offset 0;"'
|
||||
)
|
||||
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0].name).toBe('John Doe') // Alphabetically first
|
||||
expect(result[1].name).toBe('John Smith')
|
||||
expect(result.every((row: any) => row.id > 10)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
withTestDatabase('should generate valid insert with returning clause', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.insert([{ name: 'John Doe' }], { returning: true })
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
`"insert into public.normal_table (name) select name from jsonb_populate_recordset(null::public.normal_table, '[{"name":"John Doe"}]') returning *;"`
|
||||
)
|
||||
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].name).toBe('John Doe')
|
||||
})
|
||||
|
||||
withTestDatabase('should generate valid update with filtering', async (db) => {
|
||||
await db.executeQuery(`
|
||||
-- Clear and insert test data
|
||||
DELETE FROM "public"."normal_table";
|
||||
INSERT INTO "public"."normal_table" (id, name)
|
||||
VALUES (1, 'Original Name') ON CONFLICT (id) DO UPDATE SET name = 'Original Name';
|
||||
`)
|
||||
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.update({ name: 'Updated Name' }, { returning: true })
|
||||
.filter('id', '=', 1)
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(`"update public.normal_table set (name) = (select name from json_populate_record(null::public.normal_table, '{"name":"Updated Name"}')) where id = 1 returning *;"`)
|
||||
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].id).toBe(1)
|
||||
expect(result[0].name).toBe('Updated Name')
|
||||
|
||||
// Verify the update was actually persisted
|
||||
const verifyResult = await db.executeQuery('SELECT * FROM public.normal_table WHERE id = 1')
|
||||
expect(verifyResult[0].name).toBe('Updated Name')
|
||||
})
|
||||
|
||||
withTestDatabase('should generate valid delete with filtering', async (db) => {
|
||||
await db.executeQuery(`
|
||||
-- Clear and insert test data
|
||||
DELETE FROM "public"."normal_table";
|
||||
INSERT INTO "public"."normal_table" (id, name)
|
||||
VALUES (1, 'To Be Deleted') ON CONFLICT (id) DO UPDATE SET name = 'To Be Deleted';
|
||||
`)
|
||||
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.delete({ returning: true })
|
||||
.filter('id', '=', 1)
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"delete from public.normal_table where id = 1 returning *;"'
|
||||
)
|
||||
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].id).toBe(1)
|
||||
expect(result[0].name).toBe('To Be Deleted')
|
||||
|
||||
// Verify the row was actually deleted
|
||||
const verifyResult = await db.executeQuery('SELECT * FROM public.normal_table WHERE id = 1')
|
||||
expect(verifyResult.length).toBe(0)
|
||||
})
|
||||
|
||||
withTestDatabase('should generate valid count with filtering', async (db) => {
|
||||
await db.executeQuery(`
|
||||
-- Clear and insert test data
|
||||
DELETE FROM "public"."normal_table";
|
||||
INSERT INTO "public"."normal_table" (name)
|
||||
VALUES ('John Smith'), ('John Doe'), ('Jane Doe');
|
||||
`)
|
||||
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.count()
|
||||
.filter('name', '~~', '%John%')
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select count(*) from public.normal_table where name ~~ \'%John%\';"'
|
||||
)
|
||||
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result[0].count).toBe(2) // PostgreSQL returns count as string
|
||||
})
|
||||
|
||||
withTestDatabase('should generate valid truncate query', async (db) => {
|
||||
await db.executeQuery(`
|
||||
INSERT INTO "public"."normal_table" (name)
|
||||
VALUES ('Test Row 1'), ('Test Row 2');
|
||||
`)
|
||||
|
||||
// Verify data exists
|
||||
const beforeCount = await db.executeQuery(`SELECT COUNT(*) FROM "public"."normal_table"`)
|
||||
expect(parseInt(beforeCount[0].count)).toBeGreaterThan(0)
|
||||
|
||||
const query = new Query()
|
||||
const sql = query.from('normal_table', 'public').truncate().toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot('"truncate public.normal_table;"')
|
||||
await validateSql(db, sql)
|
||||
|
||||
// Verify truncate worked
|
||||
const afterCount = await db.executeQuery(`SELECT COUNT(*) FROM "public"."normal_table"`)
|
||||
expect(parseInt(afterCount[0].count)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Corner Cases and Error Handling', () => {
|
||||
withTestDatabase('should throw error for delete without filters', async () => {
|
||||
const query = new Query()
|
||||
const action = query.from('normal_table', 'public').delete({ returning: true })
|
||||
|
||||
expect(() => action.toSql()).toThrow(/no filters/)
|
||||
})
|
||||
|
||||
withTestDatabase('should throw error for update without filters', async () => {
|
||||
const query = new Query()
|
||||
const action = query.from('normal_table', 'public').update({ name: 'Updated Name' })
|
||||
|
||||
expect(() => action.toSql()).toThrow(/no filters/)
|
||||
})
|
||||
|
||||
withTestDatabase('should throw error for insert without values', async () => {
|
||||
const query = new Query()
|
||||
// We're passing an empty array to test the runtime error
|
||||
const action = query.from('normal_table', 'public').insert([] as any, { returning: true })
|
||||
|
||||
expect(() => action.toSql()).toThrow(/no value to insert/)
|
||||
})
|
||||
|
||||
withTestDatabase('should handle special characters in values', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.select('*')
|
||||
.filter('name', '=', 'Special $ ^ & * ( ) _ + { } | : < > ? characters')
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select * from public.normal_table where name = \'Special $ ^ & * ( ) _ + { } | : < > ? characters\';"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Advanced Filtering', () => {
|
||||
withTestDatabase('should handle "in" operator with array values', async (db) => {
|
||||
await db.executeQuery(`
|
||||
DELETE FROM "public"."normal_table";
|
||||
INSERT INTO "public"."normal_table" (id, name)
|
||||
VALUES
|
||||
(1, 'Row 1'),
|
||||
(2, 'Row 2'),
|
||||
(3, 'Row 3'),
|
||||
(4, 'Row 4');
|
||||
`)
|
||||
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.select('*')
|
||||
.filter('id', 'in', [1, 2, 3])
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot('"select * from public.normal_table where id in (1,2,3);"')
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(3)
|
||||
expect(result.map((row: any) => row.id).sort()).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
withTestDatabase('should handle "is" operator with null value', async (db) => {
|
||||
await db.executeQuery(`
|
||||
DELETE FROM "public"."normal_table";
|
||||
INSERT INTO "public"."normal_table" (id, name)
|
||||
VALUES
|
||||
(1, 'Not Null'),
|
||||
(2, NULL);
|
||||
`)
|
||||
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.select('*')
|
||||
.filter('name', 'is', 'null')
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot('"select * from public.normal_table where name is null;"')
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].id).toBe(2)
|
||||
expect(result[0].name).toBeNull()
|
||||
})
|
||||
|
||||
withTestDatabase('should handle "is" operator with not null value', async (db) => {
|
||||
await db.executeQuery(`
|
||||
DELETE FROM "public"."normal_table";
|
||||
INSERT INTO "public"."normal_table" (id, name)
|
||||
VALUES
|
||||
(1, 'Not Null'),
|
||||
(2, NULL);
|
||||
`)
|
||||
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.select('*')
|
||||
.filter('name', 'is', 'not null')
|
||||
.toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select * from public.normal_table where name is not null;"'
|
||||
)
|
||||
const result = await validateSql(db, sql)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].id).toBe(1)
|
||||
expect(result[0].name).toBe('Not Null')
|
||||
})
|
||||
})
|
||||
})
|
||||
615
packages/pg-meta/test/query/query.test.ts
Normal file
615
packages/pg-meta/test/query/query.test.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
import { expect, test, describe } from 'vitest'
|
||||
import { Query } from '../../src/query/Query'
|
||||
import { QueryAction } from '../../src/query/QueryAction'
|
||||
import { QueryFilter } from '../../src/query/QueryFilter'
|
||||
import { QueryModifier } from '../../src/query/QueryModifier'
|
||||
import * as QueryUtils from '../../src/query/Query.utils'
|
||||
import type { QueryTable, Filter, Sort } from '../../src/query/types'
|
||||
|
||||
describe('Query', () => {
|
||||
test('from() should create a QueryAction with the correct table', () => {
|
||||
const query = new Query()
|
||||
const action = query.from('users', 'public')
|
||||
|
||||
expect(action).toBeInstanceOf(QueryAction)
|
||||
expect(action['table']).toEqual({ name: 'users', schema: 'public' })
|
||||
})
|
||||
|
||||
test('from() should use "public" as the default schema when not provided', () => {
|
||||
const query = new Query()
|
||||
const action = query.from('users')
|
||||
|
||||
expect(action['table']).toEqual({ name: 'users', schema: 'public' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('QueryAction', () => {
|
||||
const table: QueryTable = { name: 'users', schema: 'public' }
|
||||
|
||||
test('count() should create a QueryFilter with the correct action', () => {
|
||||
const action = new QueryAction(table)
|
||||
const filter = action.count()
|
||||
|
||||
expect(filter).toBeInstanceOf(QueryFilter)
|
||||
expect(filter['table']).toEqual(table)
|
||||
expect(filter['action']).toBe('count')
|
||||
})
|
||||
|
||||
test('delete() should create a QueryFilter with the correct action and options', () => {
|
||||
const action = new QueryAction(table)
|
||||
const filter = action.delete({ returning: true })
|
||||
|
||||
expect(filter).toBeInstanceOf(QueryFilter)
|
||||
expect(filter['table']).toEqual(table)
|
||||
expect(filter['action']).toBe('delete')
|
||||
expect(filter['actionOptions']).toEqual({ returning: true })
|
||||
})
|
||||
|
||||
test('insert() should create a QueryFilter with the correct action, values and options', () => {
|
||||
const action = new QueryAction(table)
|
||||
const values = [{ id: 1, name: 'John' }]
|
||||
const filter = action.insert(values, { returning: true })
|
||||
|
||||
expect(filter).toBeInstanceOf(QueryFilter)
|
||||
expect(filter['table']).toEqual(table)
|
||||
expect(filter['action']).toBe('insert')
|
||||
expect(filter['actionValue']).toEqual(values)
|
||||
expect(filter['actionOptions']).toEqual({ returning: true })
|
||||
})
|
||||
|
||||
test('select() should create a QueryFilter with the correct action and columns', () => {
|
||||
const action = new QueryAction(table)
|
||||
const filter = action.select('id, name')
|
||||
|
||||
expect(filter).toBeInstanceOf(QueryFilter)
|
||||
expect(filter['table']).toEqual(table)
|
||||
expect(filter['action']).toBe('select')
|
||||
expect(filter['actionValue']).toBe('id, name')
|
||||
})
|
||||
|
||||
test('update() should create a QueryFilter with the correct action, value and options', () => {
|
||||
const action = new QueryAction(table)
|
||||
const value = { name: 'John' }
|
||||
const filter = action.update(value, { returning: true })
|
||||
|
||||
expect(filter).toBeInstanceOf(QueryFilter)
|
||||
expect(filter['table']).toEqual(table)
|
||||
expect(filter['action']).toBe('update')
|
||||
expect(filter['actionValue']).toEqual(value)
|
||||
expect(filter['actionOptions']).toEqual({ returning: true })
|
||||
})
|
||||
|
||||
test('truncate() should create a QueryFilter with the correct action and options', () => {
|
||||
const action = new QueryAction(table)
|
||||
const filter = action.truncate({ returning: true })
|
||||
|
||||
expect(filter).toBeInstanceOf(QueryFilter)
|
||||
expect(filter['table']).toEqual(table)
|
||||
expect(filter['action']).toBe('truncate')
|
||||
expect(filter['actionOptions']).toEqual({ returning: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('QueryFilter', () => {
|
||||
const table: QueryTable = { name: 'users', schema: 'public' }
|
||||
|
||||
test('filter() should add a filter and return the filter instance', () => {
|
||||
const queryFilter = new QueryFilter(table, 'select', 'id, name')
|
||||
const result = queryFilter.filter('id', '=', 1)
|
||||
|
||||
expect(result).toBe(queryFilter)
|
||||
expect(queryFilter['filters']).toEqual([{ column: 'id', operator: '=', value: 1 }])
|
||||
})
|
||||
|
||||
test('match() should add multiple filters and return the filter instance', () => {
|
||||
const queryFilter = new QueryFilter(table, 'select', 'id, name')
|
||||
const result = queryFilter.match({ id: 1, name: 'John' })
|
||||
|
||||
expect(result).toBe(queryFilter)
|
||||
expect(queryFilter['filters']).toEqual([
|
||||
{ column: 'id', operator: '=', value: 1 },
|
||||
{ column: 'name', operator: '=', value: 'John' },
|
||||
])
|
||||
})
|
||||
|
||||
test('order() should add a sort and return the filter instance', () => {
|
||||
const queryFilter = new QueryFilter(table, 'select', 'id, name')
|
||||
const result = queryFilter.order('users', 'name', false, true)
|
||||
|
||||
expect(result).toBe(queryFilter)
|
||||
expect(queryFilter['sorts']).toEqual([
|
||||
{ table: 'users', column: 'name', ascending: false, nullsFirst: true },
|
||||
])
|
||||
})
|
||||
|
||||
test('range() should delegate to QueryModifier.range() and return the result', () => {
|
||||
const queryFilter = new QueryFilter(table, 'select', 'id, name')
|
||||
const result = queryFilter.range(0, 10)
|
||||
|
||||
expect(result).toBeInstanceOf(QueryModifier)
|
||||
// The pagination gets set in the QueryModifier
|
||||
expect(result['pagination']).toEqual({ offset: 0, limit: 11 })
|
||||
})
|
||||
|
||||
test('toSql() should delegate to QueryModifier.toSql() and return the SQL string', () => {
|
||||
const queryFilter = new QueryFilter(table, 'select', 'id, name')
|
||||
queryFilter.filter('id', '=', 1)
|
||||
|
||||
const result = queryFilter.toSql()
|
||||
|
||||
// Expected SQL should match the pattern from QueryUtils.selectQuery()
|
||||
expect(result).toBe('select id, name from public.users where id = 1;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('QueryModifier', () => {
|
||||
const table: QueryTable = { name: 'users', schema: 'public' }
|
||||
|
||||
test('range() should set the pagination and return the modifier instance', () => {
|
||||
const queryModifier = new QueryModifier(table, 'select', {
|
||||
actionValue: 'id, name',
|
||||
})
|
||||
const result = queryModifier.range(0, 10)
|
||||
|
||||
expect(result).toBe(queryModifier)
|
||||
expect(queryModifier['pagination']).toEqual({ offset: 0, limit: 11 })
|
||||
})
|
||||
|
||||
test('toSql() should generate the correct SQL for a count query', () => {
|
||||
const queryModifier = new QueryModifier(table, 'count')
|
||||
const result = queryModifier.toSql()
|
||||
|
||||
expect(result).toBe('select count(*) from public.users;')
|
||||
})
|
||||
|
||||
test('toSql() should generate the correct SQL for a delete query with filters', () => {
|
||||
const queryModifier = new QueryModifier(table, 'delete', {
|
||||
filters: [{ column: 'id', operator: '=', value: 1 }],
|
||||
actionOptions: { returning: true },
|
||||
})
|
||||
const result = queryModifier.toSql()
|
||||
|
||||
expect(result).toBe('delete from public.users where id = 1 returning *;')
|
||||
})
|
||||
|
||||
test('toSql() should generate the correct SQL for a select query with filters, sorts and pagination', () => {
|
||||
const queryModifier = new QueryModifier(table, 'select', {
|
||||
actionValue: 'id, name',
|
||||
filters: [{ column: 'id', operator: '>', value: 10 }],
|
||||
sorts: [{ table: 'users', column: 'name', ascending: true, nullsFirst: false }],
|
||||
})
|
||||
queryModifier.range(0, 5)
|
||||
const result = queryModifier.toSql()
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"select id, name from public.users where id > 10 order by users.name asc nulls last limit 6 offset 0;"`
|
||||
)
|
||||
})
|
||||
|
||||
test('toSql() should generate the correct SQL for a truncate query', () => {
|
||||
const queryModifier = new QueryModifier(table, 'truncate')
|
||||
const result = queryModifier.toSql()
|
||||
|
||||
expect(result).toBe('truncate public.users;')
|
||||
})
|
||||
|
||||
test('toSql() should generate the correct SQL for a truncate query with cascade', () => {
|
||||
const queryModifier = new QueryModifier(table, 'truncate', {
|
||||
actionOptions: { cascade: true },
|
||||
})
|
||||
const result = queryModifier.toSql()
|
||||
|
||||
expect(result).toBe('truncate public.users cascade;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Query.utils', () => {
|
||||
const table: QueryTable = { name: 'users', schema: 'public' }
|
||||
|
||||
describe('countQuery', () => {
|
||||
test('should generate a correct count query without filters', () => {
|
||||
const result = QueryUtils.countQuery(table)
|
||||
expect(result).toBe('select count(*) from public.users;')
|
||||
})
|
||||
|
||||
test('should generate a correct count query with filters', () => {
|
||||
const filters = [{ column: 'id', operator: '>' as const, value: 1 }]
|
||||
const result = QueryUtils.countQuery(table, { filters: filters })
|
||||
expect(result).toBe('select count(*) from public.users where id > 1;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateQuery', () => {
|
||||
test('should generate a correct truncate query without cascade', () => {
|
||||
const result = QueryUtils.truncateQuery(table)
|
||||
expect(result).toBe('truncate public.users;')
|
||||
})
|
||||
|
||||
test('should generate a correct truncate query with cascade', () => {
|
||||
const result = QueryUtils.truncateQuery(table, { cascade: true })
|
||||
expect(result).toBe('truncate public.users cascade;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteQuery', () => {
|
||||
test('should throw an error if no filters are provided', () => {
|
||||
expect(() => QueryUtils.deleteQuery(table)).toThrow()
|
||||
})
|
||||
|
||||
test('should generate a correct delete query with filters', () => {
|
||||
const filters = [{ column: 'id', operator: '=' as const, value: 1 }]
|
||||
const result = QueryUtils.deleteQuery(table, filters)
|
||||
expect(result).toBe('delete from public.users where id = 1;')
|
||||
})
|
||||
|
||||
test('should include returning clause when specified', () => {
|
||||
const filters = [{ column: 'id', operator: '=' as const, value: 1 }]
|
||||
const result = QueryUtils.deleteQuery(table, filters, { returning: true })
|
||||
expect(result).toBe('delete from public.users where id = 1 returning *;')
|
||||
})
|
||||
|
||||
test('should include enum array columns in returning clause when specified', () => {
|
||||
const filters = [{ column: 'id', operator: '=' as const, value: 1 }]
|
||||
const result = QueryUtils.deleteQuery(table, filters, {
|
||||
returning: true,
|
||||
enumArrayColumns: ['tags'],
|
||||
})
|
||||
expect(result).toBe('delete from public.users where id = 1 returning *, tags::text[];')
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertQuery', () => {
|
||||
test('should throw an error if no values are provided', () => {
|
||||
expect(() => QueryUtils.insertQuery(table, [])).toThrow()
|
||||
})
|
||||
|
||||
test('should generate a correct insert query with values', () => {
|
||||
const values = [{ id: 1, name: 'John' }]
|
||||
const result = QueryUtils.insertQuery(table, values)
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"insert into public.users (id,name) select id,name from jsonb_populate_recordset(null::public.users, '[{"id":1,"name":"John"}]');"`
|
||||
)
|
||||
})
|
||||
|
||||
test('should include returning clause when specified', () => {
|
||||
const values = [{ id: 1, name: 'John' }]
|
||||
const result = QueryUtils.insertQuery(table, values, { returning: true })
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"insert into public.users (id,name) select id,name from jsonb_populate_recordset(null::public.users, '[{"id":1,"name":"John"}]') returning *;"`
|
||||
)
|
||||
})
|
||||
|
||||
test('should include enum array columns in returning clause when specified', () => {
|
||||
const values = [{ id: 1, name: 'John' }]
|
||||
const result = QueryUtils.insertQuery(table, values, {
|
||||
returning: true,
|
||||
enumArrayColumns: ['tags'],
|
||||
})
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"insert into public.users (id,name) select id,name from jsonb_populate_recordset(null::public.users, '[{"id":1,"name":"John"}]') returning *, tags::text[];"`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectQuery', () => {
|
||||
test('should generate a correct select query without options', () => {
|
||||
const result = QueryUtils.selectQuery(table)
|
||||
expect(result).toBe('select * from public.users;')
|
||||
})
|
||||
|
||||
test('should generate a correct select query with custom columns', () => {
|
||||
const result = QueryUtils.selectQuery(table, 'id, name')
|
||||
expect(result).toBe('select id, name from public.users;')
|
||||
})
|
||||
|
||||
test('should generate a correct select query with filters', () => {
|
||||
const filters = [{ column: 'id', operator: '>' as const, value: 1 }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe('select * from public.users where id > 1;')
|
||||
})
|
||||
|
||||
test('should generate a correct select query with sorts', () => {
|
||||
const sorts = [{ table: 'users', column: 'name', ascending: true, nullsFirst: false }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { sorts: sorts })
|
||||
expect(result).toBe('select * from public.users order by users.name asc nulls last;')
|
||||
})
|
||||
|
||||
test('should generate a correct select query with pagination', () => {
|
||||
const pagination = { limit: 10, offset: 0 }
|
||||
const result = QueryUtils.selectQuery(table, '*', { pagination: pagination })
|
||||
expect(result).toBe('select * from public.users limit 10 offset 0;')
|
||||
})
|
||||
|
||||
test('should ignore sorts with undefined column', () => {
|
||||
const sorts: Sort[] = [{ table: 'users', column: '', ascending: true, nullsFirst: false }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { sorts: sorts })
|
||||
expect(result).toMatchInlineSnapshot(`"select * from public.users;"`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateQuery', () => {
|
||||
test('should throw an error if no filters are provided', () => {
|
||||
const value = { name: 'John' }
|
||||
expect(() => QueryUtils.updateQuery(table, value)).toThrow()
|
||||
})
|
||||
|
||||
test('should generate a correct update query with filters', () => {
|
||||
const value = { name: 'John' }
|
||||
const filters = [{ column: 'id', operator: '=' as const, value: 1 }]
|
||||
const result = QueryUtils.updateQuery(table, value, { filters: filters })
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"update public.users set (name) = (select name from json_populate_record(null::public.users, '{"name":"John"}')) where id = 1;"`
|
||||
)
|
||||
})
|
||||
|
||||
test('should include returning clause when specified', () => {
|
||||
const value = { name: 'John' }
|
||||
const filters = [{ column: 'id', operator: '=' as const, value: 1 }]
|
||||
const result = QueryUtils.updateQuery(table, value, {
|
||||
filters: filters,
|
||||
returning: true,
|
||||
})
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"update public.users set (name) = (select name from json_populate_record(null::public.users, '{"name":"John"}')) where id = 1 returning *;"`
|
||||
)
|
||||
})
|
||||
|
||||
test('should include enum array columns in returning clause when specified', () => {
|
||||
const value = { name: 'John' }
|
||||
const filters = [{ column: 'id', operator: '=' as const, value: 1 }]
|
||||
const result = QueryUtils.updateQuery(table, value, {
|
||||
filters: filters,
|
||||
returning: true,
|
||||
enumArrayColumns: ['tags'],
|
||||
})
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"update public.users set (name) = (select name from json_populate_record(null::public.users, '{"name":"John"}')) where id = 1 returning *, tags::text[];"`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Query.utils internal functions', () => {
|
||||
describe('applyFilters', () => {
|
||||
test('should correctly apply equality filters', () => {
|
||||
const filters: Filter[] = [{ column: 'name', operator: '=', value: 'John' }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe("select * from public.users where name = 'John';")
|
||||
})
|
||||
|
||||
test('should correctly apply multiple filters with AND logic', () => {
|
||||
const filters: Filter[] = [
|
||||
{ column: 'name', operator: '=', value: 'John' },
|
||||
{ column: 'age', operator: '>', value: 25 },
|
||||
]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe("select * from public.users where name = 'John' and age > 25;")
|
||||
})
|
||||
|
||||
test('should correctly handle "in" operator with array values', () => {
|
||||
const filters: Filter[] = [{ column: 'id', operator: 'in', value: [1, 2, 3] }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe('select * from public.users where id in (1,2,3);')
|
||||
})
|
||||
|
||||
test('should correctly handle "in" operator with comma-separated string', () => {
|
||||
const filters: Filter[] = [{ column: 'id', operator: 'in', value: '1,2,3' }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe("select * from public.users where id in ('1','2','3');")
|
||||
})
|
||||
|
||||
test('should correctly handle "is" operator with null value', () => {
|
||||
const filters: Filter[] = [{ column: 'email', operator: 'is', value: 'null' }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe('select * from public.users where email is null;')
|
||||
})
|
||||
|
||||
test('should correctly handle "is" operator with not null value', () => {
|
||||
const filters: Filter[] = [{ column: 'email', operator: 'is', value: 'not null' }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe('select * from public.users where email is not null;')
|
||||
})
|
||||
|
||||
test('should correctly handle "is" operator with boolean values', () => {
|
||||
const filters: Filter[] = [{ column: 'active', operator: 'is', value: 'true' }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe('select * from public.users where active is true;')
|
||||
})
|
||||
|
||||
test('should correctly escape string values in filters', () => {
|
||||
const filters: Filter[] = [{ column: 'name', operator: '=', value: "O'Reilly" }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toContain("where name = 'O''Reilly'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('applySorts', () => {
|
||||
test('should correctly apply a single sort with default options', () => {
|
||||
const sorts: Sort[] = [
|
||||
{ table: 'users', column: 'name', ascending: true, nullsFirst: false },
|
||||
]
|
||||
const result = QueryUtils.selectQuery(table, '*', { sorts: sorts })
|
||||
expect(result).toBe('select * from public.users order by users.name asc nulls last;')
|
||||
})
|
||||
|
||||
test('should correctly apply a descending sort', () => {
|
||||
const sorts: Sort[] = [
|
||||
{ table: 'users', column: 'name', ascending: false, nullsFirst: false },
|
||||
]
|
||||
const result = QueryUtils.selectQuery(table, '*', { sorts: sorts })
|
||||
expect(result).toBe('select * from public.users order by users.name desc nulls last;')
|
||||
})
|
||||
|
||||
test('should correctly apply nulls first option', () => {
|
||||
const sorts: Sort[] = [
|
||||
{ table: 'users', column: 'name', ascending: true, nullsFirst: true },
|
||||
]
|
||||
const result = QueryUtils.selectQuery(table, '*', { sorts: sorts })
|
||||
expect(result).toBe('select * from public.users order by users.name asc nulls first;')
|
||||
})
|
||||
|
||||
test('should correctly apply multiple sorts', () => {
|
||||
const sorts: Sort[] = [
|
||||
{ table: 'users', column: 'last_name', ascending: true, nullsFirst: false },
|
||||
{ table: 'users', column: 'first_name', ascending: true, nullsFirst: false },
|
||||
]
|
||||
const result = QueryUtils.selectQuery(table, '*', { sorts: sorts })
|
||||
expect(result).toBe(
|
||||
'select * from public.users order by users.last_name asc nulls last, users.first_name asc nulls last;'
|
||||
)
|
||||
})
|
||||
|
||||
test('should ignore sorts with undefined column', () => {
|
||||
const sorts: Sort[] = [{ table: 'users', column: '', ascending: true, nullsFirst: false }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { sorts: sorts })
|
||||
expect(result).toMatchInlineSnapshot(`"select * from public.users;"`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterLiteral', () => {
|
||||
test('should correctly handle array literal syntax', () => {
|
||||
const filters: Filter[] = [{ column: 'tags', operator: '=', value: "ARRAY['tag1','tag2']" }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe("select * from public.users where tags = ARRAY['tag1','tag2'];")
|
||||
})
|
||||
|
||||
test('should correctly handle non-string values', () => {
|
||||
const filters: Filter[] = [{ column: 'active', operator: '=', value: true }]
|
||||
const result = QueryUtils.selectQuery(table, '*', { filters: filters })
|
||||
expect(result).toBe('select * from public.users where active = true;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTable', () => {
|
||||
test('should correctly format the table name with schema', () => {
|
||||
const result = QueryUtils.selectQuery({ name: 'orders', schema: 'shop' })
|
||||
expect(result).toBe('select * from shop.orders;')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('End-to-end query chaining', () => {
|
||||
test('should correctly build a simple select query', () => {
|
||||
const query = new Query()
|
||||
const sql = query.from('users', 'public').select('id, name, email').toSql()
|
||||
|
||||
expect(sql).toBe('select id, name, email from public.users;')
|
||||
})
|
||||
|
||||
test('should correctly build a filtered select query', () => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('users', 'public')
|
||||
.select('id, name, email')
|
||||
.filter('id', '>', 10)
|
||||
.toSql()
|
||||
|
||||
expect(sql).toBe('select id, name, email from public.users where id > 10;')
|
||||
})
|
||||
|
||||
test('should correctly build a select query with multiple filters', () => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('users', 'public')
|
||||
.select('id, name, email')
|
||||
.filter('id', '>', 10)
|
||||
.filter('name', '~~', '%John%')
|
||||
.toSql()
|
||||
|
||||
expect(sql).toBe("select id, name, email from public.users where id > 10 and name ~~ '%John%';")
|
||||
})
|
||||
|
||||
test('should correctly build a select query with match criteria', () => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('users', 'public')
|
||||
.select('id, name, email')
|
||||
.match({ active: true, role: 'admin' })
|
||||
.toSql()
|
||||
|
||||
expect(sql).toBe(
|
||||
"select id, name, email from public.users where active = true and role = 'admin';"
|
||||
)
|
||||
})
|
||||
|
||||
test('should correctly build a select query with sorting', () => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('users', 'public')
|
||||
.select('id, name, email')
|
||||
.order('users', 'name', true, false)
|
||||
.toSql()
|
||||
|
||||
expect(sql).toBe('select id, name, email from public.users order by users.name asc nulls last;')
|
||||
})
|
||||
|
||||
test('should correctly build a select query with pagination', () => {
|
||||
const query = new Query()
|
||||
const sql = query.from('users', 'public').select('id, name, email').range(0, 9).toSql()
|
||||
|
||||
expect(sql).toBe('select id, name, email from public.users limit 10 offset 0;')
|
||||
})
|
||||
|
||||
test('should correctly build a complete select query with filters, sorting and pagination', () => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('users', 'public')
|
||||
.select('id, name, email')
|
||||
.filter('id', '>', 10)
|
||||
.match({ active: true })
|
||||
.order('users', 'name', true, false)
|
||||
.range(0, 9)
|
||||
.toSql()
|
||||
|
||||
expect(sql).toBe(
|
||||
'select id, name, email from public.users where id > 10 and active = true order by users.name asc nulls last limit 10 offset 0;'
|
||||
)
|
||||
})
|
||||
|
||||
test('should correctly build an insert query', () => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('users', 'public')
|
||||
.insert([{ name: 'John', email: 'john@example.com' }], { returning: true })
|
||||
.toSql()
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
`"insert into public.users (name,email) select name,email from jsonb_populate_recordset(null::public.users, '[{"name":"John","email":"john@example.com"}]') returning *;"`
|
||||
)
|
||||
})
|
||||
|
||||
test('should correctly build an update query', () => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('users', 'public')
|
||||
.update({ name: 'Updated Name' }, { returning: true })
|
||||
.filter('id', '=', 1)
|
||||
.toSql()
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
`"update public.users set (name) = (select name from json_populate_record(null::public.users, '{"name":"Updated Name"}')) where id = 1 returning *;"`
|
||||
)
|
||||
})
|
||||
|
||||
test('should correctly build a delete query', () => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('users', 'public')
|
||||
.delete({ returning: true })
|
||||
.filter('id', '=', 1)
|
||||
.toSql()
|
||||
|
||||
expect(sql).toBe('delete from public.users where id = 1 returning *;')
|
||||
})
|
||||
|
||||
test('should correctly build a count query', () => {
|
||||
const query = new Query()
|
||||
const sql = query.from('users', 'public').count().filter('active', '=', true).toSql()
|
||||
|
||||
expect(sql).toBe('select count(*) from public.users where active = true;')
|
||||
})
|
||||
|
||||
test('should correctly build a truncate query', () => {
|
||||
const query = new Query()
|
||||
const sql = query.from('users', 'public').truncate().toSql()
|
||||
|
||||
expect(sql).toBe('truncate public.users;')
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "../tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ES2021",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["."],
|
||||
|
||||
Reference in New Issue
Block a user