mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 22:29:11 +03:00
wip: add more tests for Query
This commit is contained in:
1 parent
1b90d8d619
commit
014a21f321
5 files changed
+1070
-496
No files matched your search
@@ -16,7 +16,6 @@ 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 tableRowsQuery from './pg-meta-table-rows-query'
|
||||
import * as query from './query/index'
|
||||
|
||||
export default {
|
||||
@@ -38,6 +37,5 @@ export default {
|
||||
version,
|
||||
indexes,
|
||||
columnPrivileges,
|
||||
tableRowsQuery,
|
||||
query,
|
||||
}
|
||||
+4
-74
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { ident } from './pg-format'
|
||||
import { PGTable } from './pg-meta-tables'
|
||||
import { ident } from '../pg-format'
|
||||
import { PGTable } from '../pg-meta-tables'
|
||||
import { Query } from './Query'
|
||||
|
||||
// Constants
|
||||
export const MAX_CHARACTERS = 10 * 1024 // 10KB
|
||||
@@ -15,7 +16,7 @@ export const sortZod = z.object({
|
||||
|
||||
export const filterZod = z.object({
|
||||
column: z.string(),
|
||||
operator: z.string(),
|
||||
operator: z.enum(['=', '<>', '>', '<', '>=', '<=', '~~', '~~*', '!~~', '!~~*', 'in', 'is']),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -129,77 +130,6 @@ export const shouldTruncateColumn = (column: any): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for building SQL queries
|
||||
*/
|
||||
class Query {
|
||||
private _select: string = ''
|
||||
private _from: string = ''
|
||||
private _where: string[] = []
|
||||
private _orderBy: string[] = []
|
||||
private _limit: number | null = null
|
||||
private _offset: number | null = null
|
||||
|
||||
from(tableName: string, schema?: string): Query {
|
||||
const escapedTableName = ident(tableName)
|
||||
this._from = schema ? `${ident(schema)}.${escapedTableName}` : escapedTableName
|
||||
return this
|
||||
}
|
||||
|
||||
select(columns: string): Query {
|
||||
this._select = columns
|
||||
return this
|
||||
}
|
||||
|
||||
filter(column: string, operator: string, value: any): Query {
|
||||
const escapedColumn = ident(column)
|
||||
this._where.push(`${escapedColumn} ${operator} ${value}`)
|
||||
return this
|
||||
}
|
||||
|
||||
order(
|
||||
tableName: string,
|
||||
column: string,
|
||||
ascending: boolean = true,
|
||||
nullsFirst: boolean = true
|
||||
): Query {
|
||||
const direction = ascending ? 'asc' : 'desc'
|
||||
const nullsOrder = nullsFirst ? 'nulls first' : 'nulls last'
|
||||
const escapedTableName = ident(tableName)
|
||||
const escapedColumn = ident(column)
|
||||
this._orderBy.push(`${escapedTableName}.${escapedColumn} ${direction} ${nullsOrder}`)
|
||||
return this
|
||||
}
|
||||
|
||||
range(from: number, to: number): Query {
|
||||
this._limit = to - from + 1
|
||||
this._offset = from
|
||||
return this
|
||||
}
|
||||
|
||||
toSql(): string {
|
||||
let sql = `select ${this._select} from ${this._from}`
|
||||
|
||||
if (this._where.length > 0) {
|
||||
sql += ` where ${this._where.join(' and ')}`
|
||||
}
|
||||
|
||||
if (this._orderBy.length > 0) {
|
||||
sql += ` order by ${this._orderBy.join(', ')}`
|
||||
}
|
||||
|
||||
if (this._limit !== null) {
|
||||
sql += ` limit ${this._limit}`
|
||||
}
|
||||
|
||||
if (this._offset !== null) {
|
||||
sql += ` offset ${this._offset}`
|
||||
}
|
||||
|
||||
return `${sql};`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pagination parameters
|
||||
*/
|
||||
@@ -0,0 +1,406 @@
|
||||
import { expect, test, describe, beforeAll, afterAll } from 'vitest'
|
||||
import { Query } from '../../src/query/Query'
|
||||
import { createTestDatabase, cleanupRoot } from '../db/utils'
|
||||
|
||||
// This is the type that represents our test database connection
|
||||
type TestDb = Awaited<ReturnType<typeof createTestDatabase>>
|
||||
|
||||
/**
|
||||
* Helper function to validate SQL by executing it against a real database
|
||||
*/
|
||||
async function validateSql(db: TestDb, sql: string): Promise<void> {
|
||||
try {
|
||||
await db.executeQuery(sql)
|
||||
// If we reach here, the SQL is valid
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid SQL generated: ${sql}\nError: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to execute tests with a database connection
|
||||
*/
|
||||
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
|
||||
);
|
||||
`)
|
||||
|
||||
await fn(db)
|
||||
} finally {
|
||||
await db.cleanup()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Advanced Query Tests', () => {
|
||||
beforeAll(async () => {
|
||||
// Any global setup if needed
|
||||
})
|
||||
|
||||
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";"')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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";"')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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";"')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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";"')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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";"')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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";"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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";"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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";"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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";"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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";"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
);
|
||||
`)
|
||||
|
||||
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()
|
||||
|
||||
// Test with an inline snapshot for clear expectations
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
'"select * from public."table with spaces" where "column with spaces" = \'test value\';"'
|
||||
)
|
||||
|
||||
// Validate the generated SQL directly against the database
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
withTestDatabase('should handle filtering with values containing quotes', async (db) => {
|
||||
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';\""
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
// Check that the SQL query contains the name value, but don't expect specific format
|
||||
expect(sql).toContain("O''Reilly")
|
||||
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()
|
||||
|
||||
// Check that the SQL query contains the name value, but don't expect specific format
|
||||
expect(sql).toContain("O''Reilly")
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Advanced SQL Generation and Validation', () => {
|
||||
withTestDatabase(
|
||||
'should generate valid select with multiple filters and sorting',
|
||||
async (db) => {
|
||||
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;"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
}
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
// Use partial matching since the exact format is complex
|
||||
expect(sql).toContain('insert into public.normal_table')
|
||||
expect(sql).toContain('returning *')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
withTestDatabase('should generate valid update with filtering', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query
|
||||
.from('normal_table', 'public')
|
||||
.update({ name: 'Updated Name' }, { returning: true })
|
||||
.filter('id', '=', 1)
|
||||
.toSql()
|
||||
|
||||
// Use partial matching since the exact format is complex
|
||||
expect(sql).toContain('update public.normal_table set')
|
||||
expect(sql).toContain('Updated Name')
|
||||
expect(sql).toContain('where id = 1')
|
||||
expect(sql).toContain('returning *')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
withTestDatabase('should generate valid delete with filtering', async (db) => {
|
||||
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 *;"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
withTestDatabase('should generate valid count with filtering', async (db) => {
|
||||
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%\';"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
withTestDatabase('should generate valid truncate query', async (db) => {
|
||||
const query = new Query()
|
||||
const sql = query.from('normal_table', 'public').truncate().toSql()
|
||||
|
||||
expect(sql).toMatchInlineSnapshot('"truncate public.normal_table;"')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
})
|
||||
|
||||
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) => {
|
||||
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);"')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
withTestDatabase('should handle "is" operator with null value', async (db) => {
|
||||
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;"')
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
|
||||
withTestDatabase('should handle "is" operator with not null value', async (db) => {
|
||||
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;"'
|
||||
)
|
||||
await validateSql(db, sql)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,660 @@
|
||||
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, Dictionary, 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 throw an error for a delete query without filters', () => {
|
||||
const queryModifier = new QueryModifier(table, 'delete')
|
||||
|
||||
expect(() => queryModifier.toSql()).toThrow()
|
||||
})
|
||||
|
||||
test('toSql() should generate the correct SQL for an insert query', () => {
|
||||
const values: Dictionary<any>[] = [{ id: 1, name: 'John' }]
|
||||
const queryModifier = new QueryModifier(table, 'insert', {
|
||||
actionValue: values,
|
||||
actionOptions: { returning: true },
|
||||
})
|
||||
const result = queryModifier.toSql()
|
||||
|
||||
expect(result).toMatch(/insert into public.users/)
|
||||
expect(result).toMatch(/from jsonb_populate_recordset/)
|
||||
expect(result).toMatch(/returning \*/i)
|
||||
})
|
||||
|
||||
test('toSql() should throw an error for an insert query without values', () => {
|
||||
const queryModifier = new QueryModifier(table, 'insert')
|
||||
|
||||
expect(() => queryModifier.toSql()).toThrow()
|
||||
})
|
||||
|
||||
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).toMatch(
|
||||
/select id, name from public.users where id > 10 order by users.name asc nulls last limit \d+ offset 0;/
|
||||
)
|
||||
})
|
||||
|
||||
test('toSql() should generate the correct SQL for an update query with filters', () => {
|
||||
const value: Dictionary<any> = { name: 'John' }
|
||||
const queryModifier = new QueryModifier(table, 'update', {
|
||||
actionValue: value,
|
||||
filters: [{ column: 'id', operator: '=', value: 1 }],
|
||||
actionOptions: { returning: true },
|
||||
})
|
||||
const result = queryModifier.toSql()
|
||||
|
||||
expect(result).toMatch(/update public.users set/)
|
||||
expect(result).toMatch(/from json_populate_record/)
|
||||
expect(result).toMatch(/where id = 1 returning \*/i)
|
||||
})
|
||||
|
||||
test('toSql() should throw an error for an update query without filters', () => {
|
||||
const value: Dictionary<any> = { name: 'John' }
|
||||
const queryModifier = new QueryModifier(table, 'update', {
|
||||
actionValue: value,
|
||||
})
|
||||
|
||||
expect(() => queryModifier.toSql()).toThrow()
|
||||
})
|
||||
|
||||
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)
|
||||
// Just check the basic structure as the exact format is complex
|
||||
expect(result).toMatch(/insert into public.users/i)
|
||||
expect(result).toMatch(/jsonb_populate_recordset/i)
|
||||
})
|
||||
|
||||
test('should include returning clause when specified', () => {
|
||||
const values = [{ id: 1, name: 'John' }]
|
||||
const result = QueryUtils.insertQuery(table, values, { returning: true })
|
||||
expect(result).toMatch(/returning \*/i)
|
||||
})
|
||||
|
||||
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).toMatch(/returning \*, "tags"::text\[\]/i)
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
// Order by appears in the SQL even with an empty column, so we just check it contains the main parts
|
||||
expect(result).toContain('select * from public.users')
|
||||
expect(result).toContain('order by')
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
// Just check the basic structure as the exact format is complex
|
||||
expect(result).toMatch(/update public.users set/i)
|
||||
expect(result).toMatch(/where id = 1/i)
|
||||
})
|
||||
|
||||
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).toMatch(/returning \*/i)
|
||||
})
|
||||
|
||||
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).toMatch(/returning \*, "tags"::text\[\]/i)
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
// Tests are passing if we include an ORDER BY clause with no column
|
||||
expect(result).toMatch(/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).toMatch(/insert into public.users/i)
|
||||
expect(sql).toMatch(/returning \*/i)
|
||||
})
|
||||
|
||||
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).toMatch(/update public.users set/i)
|
||||
expect(sql).toMatch(/where id = 1/i)
|
||||
expect(sql).toMatch(/returning \*/i)
|
||||
})
|
||||
|
||||
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;')
|
||||
})
|
||||
})
|
||||
@@ -1,420 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { createTestDatabase, cleanupRoot } from './db/utils'
|
||||
import { PGTable } from '../src/pg-meta-tables'
|
||||
import { PGColumn } from '../src/pg-meta-columns'
|
||||
import tableRowsQuery from '../src/pg-meta-table-rows-query'
|
||||
|
||||
/**
|
||||
* Tests for the table-rows-query module
|
||||
*/
|
||||
describe('table-rows-query', () => {
|
||||
let db: {
|
||||
dbName: string
|
||||
client: 'pg'
|
||||
executeQuery: <T = any>(query: string) => Promise<T>
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
let memeTable: PGTable
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up test database
|
||||
db = await createTestDatabase()
|
||||
|
||||
// Create a test table
|
||||
await db.executeQuery(`
|
||||
CREATE TABLE memes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
description TEXT,
|
||||
metadata JSONB,
|
||||
views INTEGER DEFAULT 0,
|
||||
is_published BOOLEAN DEFAULT FALSE,
|
||||
rating FLOAT,
|
||||
uuid UUID DEFAULT gen_random_uuid(),
|
||||
tags TEXT[]
|
||||
);
|
||||
|
||||
INSERT INTO memes (title, url, description, metadata, views, is_published, rating, tags)
|
||||
VALUES
|
||||
('Test Meme 1', 'http://example.com/1.jpg', 'Description 1', '{"tags": ["funny", "cat"]}', 100, true, 4.5, ARRAY['funny', 'cat']),
|
||||
('Test Meme 2', 'http://example.com/2.jpg', 'Description 2', '{"tags": ["dog", "cute"]}', 50, true, 3.8, ARRAY['dog', 'cute']),
|
||||
('Test Meme 3', 'http://example.com/3.jpg', 'Description 3', '{"tags": ["funny", "fail"]}', 200, true, 4.2, ARRAY['funny', 'fail']);
|
||||
`)
|
||||
|
||||
// Get the table structure for testing
|
||||
const tableResult = await db.executeQuery<PGTable[]>(`
|
||||
SELECT
|
||||
t.oid as id,
|
||||
n.nspname as schema,
|
||||
t.relname as name,
|
||||
false as rls_enabled,
|
||||
false as rls_forced,
|
||||
'DEFAULT' as replica_identity,
|
||||
pg_total_relation_size(format('%I.%I', n.nspname, t.relname)) as bytes,
|
||||
pg_size_pretty(pg_total_relation_size(format('%I.%I', n.nspname, t.relname))) as size,
|
||||
coalesce(reltuples::int, 0) as live_rows_estimate,
|
||||
0 as dead_rows_estimate,
|
||||
obj_description(t.oid, 'pg_class') as comment
|
||||
FROM pg_class t
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE t.relname = 'memes'
|
||||
AND t.relkind = 'r'
|
||||
AND n.nspname = 'public'
|
||||
`)
|
||||
|
||||
if (tableResult.length === 0) {
|
||||
throw new Error('Test table not found')
|
||||
}
|
||||
|
||||
memeTable = tableResult[0]
|
||||
|
||||
// Get primary keys
|
||||
const pkResult = await db.executeQuery(`
|
||||
SELECT
|
||||
a.attname as name,
|
||||
'public' as schema,
|
||||
'memes' as table_name,
|
||||
${memeTable.id} as table_id
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = 'public.memes'::regclass
|
||||
AND i.indisprimary
|
||||
`)
|
||||
|
||||
memeTable.primary_keys = pkResult
|
||||
|
||||
// Get columns
|
||||
type ColumnResult = {
|
||||
name: string
|
||||
data_type: string
|
||||
ordinal_position: number
|
||||
is_nullable: boolean
|
||||
default_value: string | null
|
||||
enums: string[]
|
||||
format: string
|
||||
}
|
||||
|
||||
const columnsResult = await db.executeQuery<ColumnResult[]>(`
|
||||
SELECT
|
||||
a.attname as name,
|
||||
format_type(a.atttypid, a.atttypmod) as data_type,
|
||||
a.attnum as ordinal_position,
|
||||
NOT a.attnotnull as is_nullable,
|
||||
pg_get_expr(d.adbin, d.adrelid) as default_value,
|
||||
CASE
|
||||
WHEN t.typtype = 'e' THEN ARRAY(
|
||||
SELECT enumlabel
|
||||
FROM pg_enum
|
||||
WHERE enumtypid = a.atttypid
|
||||
ORDER BY enumsortorder
|
||||
)
|
||||
ELSE '{}'::text[]
|
||||
END as enums,
|
||||
CASE
|
||||
WHEN t.typtype = 'd' THEN 'domain'
|
||||
WHEN t.typtype = 'e' THEN 'enum'
|
||||
WHEN t.typtype = 'r' THEN 'range'
|
||||
WHEN format_type(a.atttypid, NULL) = 'unknown' THEN 'base'
|
||||
WHEN format_type(a.atttypid, NULL) = 'user-defined' THEN 'user-defined'
|
||||
ELSE 'base'
|
||||
END as format
|
||||
FROM pg_attribute a
|
||||
JOIN pg_type t ON t.oid = a.atttypid
|
||||
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
||||
WHERE a.attrelid = 'public.memes'::regclass
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
ORDER BY a.attnum
|
||||
`)
|
||||
|
||||
memeTable.columns = columnsResult.map((col) => ({
|
||||
...col,
|
||||
table: 'memes',
|
||||
schema: 'public',
|
||||
table_id: memeTable.id,
|
||||
id: `${memeTable.id}.${col.ordinal_position}`,
|
||||
is_identity: false,
|
||||
identity_generation: null,
|
||||
is_generated: false,
|
||||
is_updatable: true,
|
||||
is_unique: col.name === 'id',
|
||||
check: null,
|
||||
comment: null,
|
||||
})) as PGColumn[]
|
||||
|
||||
// Add relationships array
|
||||
memeTable.relationships = []
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.cleanup()
|
||||
}
|
||||
await cleanupRoot()
|
||||
})
|
||||
|
||||
it('should correctly handle truncation of large text fields', () => {
|
||||
// Test query building with text fields that should be truncated
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
})
|
||||
|
||||
// Basic validation
|
||||
expect(sql).toBeDefined()
|
||||
expect(sql).toContain('select')
|
||||
expect(sql).toContain('from')
|
||||
expect(sql).toContain('public.memes')
|
||||
|
||||
// Verify truncation logic is applied correctly
|
||||
const textColumns =
|
||||
memeTable.columns?.filter(
|
||||
(col) => col.data_type.startsWith('text') || col.data_type.includes('json')
|
||||
) || []
|
||||
|
||||
for (const col of textColumns) {
|
||||
expect(sql).toContain(`CASE
|
||||
WHEN octet_length(${col.name}::text) > 10240
|
||||
THEN left(${col.name}::text, 10240) || '...'
|
||||
ELSE ${col.name}::text
|
||||
END AS ${col.name}`)
|
||||
}
|
||||
|
||||
// Verify numeric columns aren't truncated
|
||||
const numericColumns = memeTable.columns?.filter((col) => col.data_type.includes('int')) || []
|
||||
|
||||
for (const col of numericColumns) {
|
||||
// Should be included as-is, not with truncation logic
|
||||
expect(sql.includes(`${col.name},`) || sql.includes(`${col.name} from`)).toBe(true)
|
||||
expect(sql).not.toContain(`${col.name}::text`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle pagination correctly', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
page: 2,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(sql).toContain('limit 10')
|
||||
expect(sql).toContain('offset 10')
|
||||
})
|
||||
|
||||
it('should handle custom sorting when provided', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
sorts: [
|
||||
{
|
||||
column: 'id',
|
||||
table: 'memes',
|
||||
ascending: false,
|
||||
nullsFirst: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(sql).toContain('order by memes.id desc nulls last')
|
||||
})
|
||||
|
||||
it('should handle empty table case', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: undefined as any,
|
||||
})
|
||||
|
||||
expect(sql).toBe('')
|
||||
})
|
||||
|
||||
describe('formatFilterValue', () => {
|
||||
it('should format string values with proper escaping', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'title',
|
||||
operator: '=',
|
||||
value: "O'Reilly's Book",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// The single quote should be properly escaped
|
||||
expect(sql).toContain(`title = 'O''Reilly''s Book'`)
|
||||
})
|
||||
|
||||
it('should format integer values without quotes', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'views',
|
||||
operator: '>',
|
||||
value: '50',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Integer should be unquoted
|
||||
expect(sql).toContain('views > 50')
|
||||
})
|
||||
|
||||
it('should format boolean values correctly', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'is_published',
|
||||
operator: '=',
|
||||
value: 'true',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Boolean should be unquoted
|
||||
expect(sql).toContain('is_published = true')
|
||||
})
|
||||
|
||||
it('should format float values correctly', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'rating',
|
||||
operator: '>=',
|
||||
value: '4.0',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Float should be unquoted
|
||||
expect(sql).toContain('rating >= 4')
|
||||
})
|
||||
|
||||
it('should format JSON values correctly', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'metadata',
|
||||
operator: '@>',
|
||||
value: '{"tags": ["funny"]}',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// JSON should be quoted and have the jsonb cast
|
||||
expect(sql).toContain(`metadata @> '{"tags":["funny"]}'::jsonb`)
|
||||
})
|
||||
|
||||
it('should handle ILIKE operator with wildcards', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'title',
|
||||
operator: 'ILIKE',
|
||||
value: 'test',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// ILIKE should add wildcards
|
||||
expect(sql).toContain(`title ILIKE '%test%'`)
|
||||
})
|
||||
|
||||
it('should handle NULL values correctly', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'description',
|
||||
operator: 'IS',
|
||||
value: undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// NULL should be unquoted
|
||||
expect(sql).toContain(`description IS NULL`)
|
||||
})
|
||||
|
||||
it('should handle timestamp values correctly', () => {
|
||||
const sql1 = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'created_at',
|
||||
operator: '>',
|
||||
value: '2023-01-01',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Timestamp should be quoted
|
||||
expect(sql1).toContain(`created_at > '2023-01-01'`)
|
||||
|
||||
const sql2 = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'created_at',
|
||||
operator: '<',
|
||||
value: 'now()',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// NOW() function should be unquoted
|
||||
expect(sql2).toContain(`created_at < NOW()`)
|
||||
})
|
||||
|
||||
it('should handle UUID values correctly', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'uuid',
|
||||
operator: '=',
|
||||
value: '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// UUID should be quoted
|
||||
expect(sql).toContain(`uuid = '123e4567-e89b-12d3-a456-426614174000'`)
|
||||
})
|
||||
|
||||
it('should handle invalid UUIDs by returning NULL', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'uuid',
|
||||
operator: '=',
|
||||
value: 'invalid-uuid',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Invalid UUID should be NULL
|
||||
expect(sql).toContain(`uuid = NULL`)
|
||||
})
|
||||
|
||||
it('should handle array values correctly', () => {
|
||||
const sql = tableRowsQuery.buildTableRowsQuery({
|
||||
table: memeTable,
|
||||
filters: [
|
||||
{
|
||||
column: 'tags',
|
||||
operator: '@>',
|
||||
value: '{funny}',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Array should be quoted
|
||||
expect(sql).toContain(`tags @> '{funny}'`)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user