Files
supabase/packages/pg-meta/test/query/table-row-query.test.ts
Charis df63ce3658 fix(mssql foreign tables): disallow sort on columns filtered for equality (#40137)
There is an edge case interaction between the Postgres query parser and
MSSQL foreign tables, where the query parser may drop sort clauses that
are redundant with applied filters. This leads to invalid MSSQL syntax,
because the resulting query has a `limit` but no `sort`, and the user
sees a confusing error message.

This PR detects this edge case on MSSQL foreign tables. There are three
cases:
1. The user filters by a column, but there are still other columns
available for sorting. The default search for a sorting column will
leave out the filtered column.
2. The user filters by a column/several columns, and there are no more
columns that can be used for sorting. We stop the query and show an
admonition.
3. The user filters by a column, then tries to sort by the same column.
We stop the query and show an admonition.
2025-11-06 13:14:30 -05:00

1756 lines
55 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import pgMeta from '../../src/index'
import { Filter, Sort } from '../../src/query'
import { getDefaultOrderByColumns, getTableRowsSql } from '../../src/query/table-row-query'
import { cleanupRoot, createTestDatabase } from '../db/utils'
beforeAll(async () => {
// Any global setup if needed
})
afterAll(async () => {
await cleanupRoot()
})
type TestDb = Awaited<ReturnType<typeof createTestDatabase>>
const withTestDatabase = (name: string, fn: (db: TestDb) => Promise<void>) => {
test(name, async () => {
const db = await createTestDatabase()
try {
await fn(db)
} finally {
await db.cleanup()
}
})
}
describe('Table Row Query', () => {
describe('getDefaultOrderByColumns', () => {
test('should return empty array when no primary keys and no columns exist', () => {
const result = getDefaultOrderByColumns({
primary_keys: [],
columns: [],
})
expect(result).toEqual([])
})
test('should exclude specified columns when determining default sort', () => {
const table = {
primary_keys: [{ name: 'id' }],
columns: [
{
name: 'id',
data_type: 'integer',
format: 'int4',
ordinal_position: 1,
},
{
name: 'name',
data_type: 'text',
format: 'text',
ordinal_position: 2,
},
],
} as any
const result = getDefaultOrderByColumns(table, { excludedColumns: ['id'] })
expect(result).toEqual(['name'])
})
})
describe('getTableRowsSql', () => {
withTestDatabase('should handle array of enums correctly', async (db) => {
// Create an enum type and a table with an array of that enum
await db.executeQuery(`
-- Create enum type
CREATE TYPE status_type AS ENUM ('pending', 'active', 'completed', 'canceled');
-- Create table with array of enums
CREATE TABLE test_enum_array (
id SERIAL PRIMARY KEY,
name TEXT,
status status_type, -- Regular enum column
history status_type[] -- Array of enums
);
-- Insert test data with various enum array values
INSERT INTO test_enum_array (name, status, history) VALUES
('Item 1', 'active', ARRAY['pending', 'active']::status_type[]),
('Item 2', 'completed', ARRAY['pending', 'active', 'completed']::status_type[]),
('Item 3', 'canceled', ARRAY['active', 'canceled']::status_type[]),
('Item 4', 'pending', NULL);
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_enum_array')
expect(testTable).toBeDefined()
// Generate SQL
const sql = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(`
"with _base_query as (select * from public.test_enum_array order by test_enum_array.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,status,
case
when octet_length(history::text) > 10240
then
case
when array_ndims(history) = 1
then
(select array_cat(history[1:50]::text[], array['...']::text[]))::text[]
else
history[1:50]::text[]
end
else history::text[]
end
from _base_query;"
`)
// Execute the generated SQL and verify the results
const queryResult = await db.executeQuery(sql)
expect(queryResult).toMatchInlineSnapshot(`
[
{
"history": [
"pending",
"active",
],
"id": 1,
"name": "Item 1",
"status": "active",
},
{
"history": [
"pending",
"active",
"completed",
],
"id": 2,
"name": "Item 2",
"status": "completed",
},
{
"history": [
"active",
"canceled",
],
"id": 3,
"name": "Item 3",
"status": "canceled",
},
{
"history": null,
"id": 4,
"name": "Item 4",
"status": "pending",
},
]
`)
// Test filtering on enum array values
const filtersStatusSQL = getTableRowsSql({
table: testTable!,
filters: [
{ column: 'status', operator: '=', value: `active` }, // Contains 'active' using text pattern matching
],
page: 1,
limit: 10,
})
expect(filtersStatusSQL).toMatchInlineSnapshot(`
"with _base_query as (select * from public.test_enum_array where status = 'active' order by test_enum_array.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,status,
case
when octet_length(history::text) > 10240
then
case
when array_ndims(history) = 1
then
(select array_cat(history[1:50]::text[], array['...']::text[]))::text[]
else
history[1:50]::text[]
end
else history::text[]
end
from _base_query;"
`)
// Execute the filtered query
const filtersStatusResult = await db.executeQuery(filtersStatusSQL)
expect(filtersStatusResult).toMatchInlineSnapshot(`
[
{
"history": [
"pending",
"active",
],
"id": 1,
"name": "Item 1",
"status": "active",
},
]
`)
const filtersHistorySQL = getTableRowsSql({
table: testTable!,
filters: [
{ column: 'history', operator: '=', value: `ARRAY['active']::status_type[]` }, // Contains 'active' using text pattern matching
],
page: 1,
limit: 10,
})
expect(filtersHistorySQL).toMatchInlineSnapshot(`
"with _base_query as (select * from public.test_enum_array where history = ARRAY['active']::status_type[] order by test_enum_array.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,status,
case
when octet_length(history::text) > 10240
then
case
when array_ndims(history) = 1
then
(select array_cat(history[1:50]::text[], array['...']::text[]))::text[]
else
history[1:50]::text[]
end
else history::text[]
end
from _base_query;"
`)
const filtersHistoryResult = await db.executeQuery(filtersHistorySQL)
expect(filtersHistoryResult).toMatchInlineSnapshot(`[]`)
})
withTestDatabase('should handle array columns correctly', async (db) => {
await db.executeQuery(`
CREATE TABLE test_array_table (
id SERIAL PRIMARY KEY,
name TEXT,
tags TEXT[] -- Array of text
);
-- Insert test data with array values
INSERT INTO test_array_table (name, tags) VALUES
('Item 1', ARRAY['tag1', 'tag2']),
('Item 2', ARRAY['tag3']),
('Item 3', ARRAY['tag1', 'tag4']);
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_array_table')
expect(testTable).toBeDefined()
// Generate SQL
const sql = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
})
expect(sql).toMatchInlineSnapshot(`
"with _base_query as (select * from public.test_array_table order by test_array_table.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,
case
when octet_length(tags::text) > 10240
then
case
when array_ndims(tags) = 1
then
(select array_cat(tags[1:50]::text[], array['...']::text[]))::text[]
else
tags[1:50]::text[]
end
else tags::text[]
end
from _base_query;"
`)
const queryResult = await db.executeQuery(sql)
expect(queryResult.length).toBe(3)
expect(queryResult).toMatchInlineSnapshot(`
[
{
"id": 1,
"name": "Item 1",
"tags": [
"tag1",
"tag2",
],
},
{
"id": 2,
"name": "Item 2",
"tags": [
"tag3",
],
},
{
"id": 3,
"name": "Item 3",
"tags": [
"tag1",
"tag4",
],
},
]
`)
})
withTestDatabase('should generate basic SELECT SQL for a table', async (db) => {
// Create test table and insert data
await db.executeQuery(`
CREATE TABLE test_sql_gen (
id SERIAL PRIMARY KEY,
name TEXT,
description TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Insert test data
INSERT INTO test_sql_gen (name, description) VALUES
('Row 1', 'Description 1'),
('Row 2', 'Description 2'),
('Row 3', 'Description 3');
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_sql_gen')
expect(testTable).toBeDefined()
// Generate SQL
const sql = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(
`
"with _base_query as (select * from public.test_sql_gen order by test_sql_gen.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,case
when octet_length(description::text) > 10240
then left(description::text, 10240) || '...'
else description::text
end as description,created_at from _base_query;"
`
)
// E2E Test: Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult.length).toBe(3)
expect(queryResult.map((row: any) => row.name)).toEqual(['Row 1', 'Row 2', 'Row 3'])
expect(queryResult.map((row: any) => row.description)).toEqual([
'Description 1',
'Description 2',
'Description 3',
])
})
withTestDatabase(
'should truncate large arrays to maxArraySize elements if their size is > maxCharacters',
async (db) => {
// Create test table with array column
await db.executeQuery(`
CREATE TABLE test_large_array_table (
id SERIAL PRIMARY KEY,
name TEXT,
large_array TEXT[] -- Will hold a very large array
);
-- Insert test data with a large array (>10KB)
-- Create an array with 1000 elements to ensure it exceeds 10KB
INSERT INTO test_large_array_table (name, large_array) VALUES
('Large Array Item', (SELECT array_agg('element_' || i) FROM generate_series(1, 1000) i)),
('Large Array Small items', (SELECT array_agg('' || i) FROM generate_series(1, 100) i)),
('Normal Array Item', ARRAY['tag1', 'tag2', 'tag3']);
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_large_array_table')
expect(testTable).toBeDefined()
// Generate SQL with lower maxCharacters and maxArraySize limits
const sql = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
maxCharacters: 2048,
maxArraySize: 10,
})
// Verify the SQL contains the array truncation logic
expect(sql).toMatchInlineSnapshot(`
"with _base_query as (select * from public.test_large_array_table order by test_large_array_table.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 2048
then left(name::text, 2048) || '...'
else name::text
end as name,
case
when octet_length(large_array::text) > 2048
then
case
when array_ndims(large_array) = 1
then
(select array_cat(large_array[1:10]::text[], array['...']::text[]))::text[]
else
large_array[1:10]::text[]
end
else large_array::text[]
end
from _base_query;"
`)
// Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult).toMatchInlineSnapshot(`
[
{
"id": 1,
"large_array": [
"element_1",
"element_2",
"element_3",
"element_4",
"element_5",
"element_6",
"element_7",
"element_8",
"element_9",
"element_10",
"...",
],
"name": "Large Array Item",
},
{
"id": 2,
"large_array": [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
"17",
"18",
"19",
"20",
"21",
"22",
"23",
"24",
"25",
"26",
"27",
"28",
"29",
"30",
"31",
"32",
"33",
"34",
"35",
"36",
"37",
"38",
"39",
"40",
"41",
"42",
"43",
"44",
"45",
"46",
"47",
"48",
"49",
"50",
"51",
"52",
"53",
"54",
"55",
"56",
"57",
"58",
"59",
"60",
"61",
"62",
"63",
"64",
"65",
"66",
"67",
"68",
"69",
"70",
"71",
"72",
"73",
"74",
"75",
"76",
"77",
"78",
"79",
"80",
"81",
"82",
"83",
"84",
"85",
"86",
"87",
"88",
"89",
"90",
"91",
"92",
"93",
"94",
"95",
"96",
"97",
"98",
"99",
"100",
],
"name": "Large Array Small items",
},
{
"id": 3,
"large_array": [
"tag1",
"tag2",
"tag3",
],
"name": "Normal Array Item",
},
]
`)
}
)
withTestDatabase(
'should truncate large arrays of jsonb and json to maxArraySize elements if their size is > maxCharacters',
async (db) => {
// Create test table with array column
await db.executeQuery(`
CREATE TABLE test_large_array_table (
id SERIAL PRIMARY KEY,
name TEXT,
large_array_jsonb jsonb[],
large_array_json json[]
);
-- Insert test data with a large array (>10KB)
-- Create arrays with JSON objects
INSERT INTO test_large_array_table (name, large_array_jsonb, large_array_json) VALUES
(
'Large Array Item',
(SELECT array_agg(jsonb_build_object(
'id', i,
'name', 'element_' || i,
'data', jsonb_build_object('value', i * 10, 'active', true)
)) FROM generate_series(1, 1000) i),
(SELECT array_agg(json_build_object(
'id', i,
'name', 'element_' || i,
'data', json_build_object('value', i * 10, 'active', true)
)) FROM generate_series(1, 1000) i)
),
(
'Large Array Small items',
(SELECT array_agg(jsonb_build_object(
'id', i,
'value', i
)) FROM generate_series(1, 100) i),
(SELECT array_agg(json_build_object(
'id', i,
'value', i
)) FROM generate_series(1, 100) i)
),
(
'Normal Array Item',
ARRAY[
'{"id": 1, "tag": "tag1"}'::jsonb,
'{"id": 2, "tag": "tag2"}'::jsonb,
'{"id": 3, "tag": "tag3"}'::jsonb
],
ARRAY[
'{"id": 1, "tag": "tag1"}'::json,
'{"id": 2, "tag": "tag2"}'::json,
'{"id": 3, "tag": "tag3"}'::json
]
);
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_large_array_table')
expect(testTable).toBeDefined()
// Generate SQL with lower maxCharacters and maxArraySize limits
const sql = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
maxCharacters: 2048,
maxArraySize: 10,
})
// Verify the SQL contains the array truncation logic
expect(sql).toMatchInlineSnapshot(`
"with _base_query as (select * from public.test_large_array_table order by test_large_array_table.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 2048
then left(name::text, 2048) || '...'
else name::text
end as name,
case
when octet_length(large_array_jsonb::text) > 2048
then
case
when array_ndims(large_array_jsonb) = 1
then
(select array_cat(large_array_jsonb[1:10]::jsonb[], array['{"truncated": true}'::json]::jsonb[]))::jsonb[]
else
large_array_jsonb[1:10]::jsonb[]
end
else large_array_jsonb::jsonb[]
end
,
case
when octet_length(large_array_json::text) > 2048
then
case
when array_ndims(large_array_json) = 1
then
(select array_cat(large_array_json[1:10]::json[], array['{"truncated": true}'::json]::json[]))::json[]
else
large_array_json[1:10]::json[]
end
else large_array_json::json[]
end
from _base_query;"
`)
// Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult).toMatchInlineSnapshot(`
[
{
"id": 1,
"large_array_json": [
{
"data": {
"active": true,
"value": 10,
},
"id": 1,
"name": "element_1",
},
{
"data": {
"active": true,
"value": 20,
},
"id": 2,
"name": "element_2",
},
{
"data": {
"active": true,
"value": 30,
},
"id": 3,
"name": "element_3",
},
{
"data": {
"active": true,
"value": 40,
},
"id": 4,
"name": "element_4",
},
{
"data": {
"active": true,
"value": 50,
},
"id": 5,
"name": "element_5",
},
{
"data": {
"active": true,
"value": 60,
},
"id": 6,
"name": "element_6",
},
{
"data": {
"active": true,
"value": 70,
},
"id": 7,
"name": "element_7",
},
{
"data": {
"active": true,
"value": 80,
},
"id": 8,
"name": "element_8",
},
{
"data": {
"active": true,
"value": 90,
},
"id": 9,
"name": "element_9",
},
{
"data": {
"active": true,
"value": 100,
},
"id": 10,
"name": "element_10",
},
{
"truncated": true,
},
],
"large_array_jsonb": [
{
"data": {
"active": true,
"value": 10,
},
"id": 1,
"name": "element_1",
},
{
"data": {
"active": true,
"value": 20,
},
"id": 2,
"name": "element_2",
},
{
"data": {
"active": true,
"value": 30,
},
"id": 3,
"name": "element_3",
},
{
"data": {
"active": true,
"value": 40,
},
"id": 4,
"name": "element_4",
},
{
"data": {
"active": true,
"value": 50,
},
"id": 5,
"name": "element_5",
},
{
"data": {
"active": true,
"value": 60,
},
"id": 6,
"name": "element_6",
},
{
"data": {
"active": true,
"value": 70,
},
"id": 7,
"name": "element_7",
},
{
"data": {
"active": true,
"value": 80,
},
"id": 8,
"name": "element_8",
},
{
"data": {
"active": true,
"value": 90,
},
"id": 9,
"name": "element_9",
},
{
"data": {
"active": true,
"value": 100,
},
"id": 10,
"name": "element_10",
},
{
"truncated": true,
},
],
"name": "Large Array Item",
},
{
"id": 2,
"large_array_json": [
{
"id": 1,
"value": 1,
},
{
"id": 2,
"value": 2,
},
{
"id": 3,
"value": 3,
},
{
"id": 4,
"value": 4,
},
{
"id": 5,
"value": 5,
},
{
"id": 6,
"value": 6,
},
{
"id": 7,
"value": 7,
},
{
"id": 8,
"value": 8,
},
{
"id": 9,
"value": 9,
},
{
"id": 10,
"value": 10,
},
{
"truncated": true,
},
],
"large_array_jsonb": [
{
"id": 1,
"value": 1,
},
{
"id": 2,
"value": 2,
},
{
"id": 3,
"value": 3,
},
{
"id": 4,
"value": 4,
},
{
"id": 5,
"value": 5,
},
{
"id": 6,
"value": 6,
},
{
"id": 7,
"value": 7,
},
{
"id": 8,
"value": 8,
},
{
"id": 9,
"value": 9,
},
{
"id": 10,
"value": 10,
},
{
"truncated": true,
},
],
"name": "Large Array Small items",
},
{
"id": 3,
"large_array_json": [
{
"id": 1,
"tag": "tag1",
},
{
"id": 2,
"tag": "tag2",
},
{
"id": 3,
"tag": "tag3",
},
],
"large_array_jsonb": [
{
"id": 1,
"tag": "tag1",
},
{
"id": 2,
"tag": "tag2",
},
{
"id": 3,
"tag": "tag3",
},
],
"name": "Normal Array Item",
},
]
`)
}
)
withTestDatabase('should truncate fields to maxCharacters avoid', async (db) => {
// Create test table with array column
await db.executeQuery(`
CREATE TABLE test_large_array_table (
id SERIAL PRIMARY KEY,
name TEXT,
large_array TEXT[] -- Will hold a very large array
);
-- Insert test data with a large array (>10KB)
-- Create an array with 1000 elements to ensure it exceeds 10KB
INSERT INTO test_large_array_table (name, large_array) VALUES
('Normal Array Item', ARRAY['tag1', 'tag2', 'tag3']),
-- Locally testing with up to 700 Mo in size should work and not raise a JS string alloc size error
(repeat('A', 5 * 1024 * 1024), ARRAY['tag1', 'tag2', 'tag3']);
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_large_array_table')
expect(testTable).toBeDefined()
// Generate SQL with lower maxCharacters and maxArraySize limits
const sql = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
maxCharacters: 256,
})
// Verify the SQL contains the array truncation logic
expect(sql).toMatchInlineSnapshot(`
"with _base_query as (select * from public.test_large_array_table order by test_large_array_table.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 256
then left(name::text, 256) || '...'
else name::text
end as name,
case
when octet_length(large_array::text) > 256
then
case
when array_ndims(large_array) = 1
then
(select array_cat(large_array[1:50]::text[], array['...']::text[]))::text[]
else
large_array[1:50]::text[]
end
else large_array::text[]
end
from _base_query;"
`)
// Execute the SQL and verify results
const start = performance.now()
const queryResult = await db.executeQuery(sql)
const end = performance.now()
expect(end - start).lessThan(1000)
expect(queryResult).toMatchInlineSnapshot(`
[
{
"id": 1,
"large_array": [
"tag1",
"tag2",
"tag3",
],
"name": "Normal Array Item",
},
{
"id": 2,
"large_array": [
"tag1",
"tag2",
"tag3",
],
"name": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...",
},
]
`)
})
withTestDatabase('should generate SQL with filtering', async (db) => {
// Create test table and insert data
await db.executeQuery(`
CREATE TABLE test_sql_filter (
id SERIAL PRIMARY KEY,
name TEXT,
category TEXT
);
-- Insert test data with different categories
INSERT INTO test_sql_filter (name, category) VALUES
('Test Item 1', 'A'),
('Test Item 2', 'B'),
('Test Item 3', 'A'),
('Another Item', 'A'),
('Different Item', 'C');
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_sql_filter')
expect(testTable).toBeDefined()
// Define filters
const filters: Filter[] = [
{ column: 'name', operator: '~~', value: 'Test%' },
{ column: 'category', operator: '=', value: 'A' },
]
// Generate SQL with filters
const sql = getTableRowsSql({
table: testTable!,
filters,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(
`
"with _base_query as (select * from public.test_sql_filter where name::text ~~ 'Test%' and category = 'A' order by test_sql_filter.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,case
when octet_length(category::text) > 10240
then left(category::text, 10240) || '...'
else category::text
end as category from _base_query;"
`
)
// E2E Test: Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult.length).toBe(2) // Should only get items that match both filters
expect(queryResult.map((row: any) => row.name)).toEqual(['Test Item 1', 'Test Item 3'])
expect(queryResult.every((row: any) => row.category === 'A')).toBe(true)
})
withTestDatabase('should generate SQL with sorting', async (db) => {
// Create test table and insert data
await db.executeQuery(`
CREATE TABLE test_sql_sort (
id SERIAL PRIMARY KEY,
name TEXT,
value INTEGER
);
-- Insert test data with varying values
INSERT INTO test_sql_sort (name, value) VALUES
('Z Item', 10),
('A Item', 30),
('M Item', 20),
('X Item', null);
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_sql_sort')
expect(testTable).toBeDefined()
// Define sorts
const sorts: Sort[] = [
{ column: 'name', table: 'test_sql_sort', ascending: true, nullsFirst: false },
{ column: 'value', table: 'test_sql_sort', ascending: false, nullsFirst: true },
]
// Generate SQL with sorting
const sql = getTableRowsSql({
table: testTable!,
sorts,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(
`
"with _base_query as (select * from public.test_sql_sort order by test_sql_sort.name asc nulls last, test_sql_sort.value desc nulls first limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,value from _base_query;"
`
)
// E2E Test: Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult.length).toBe(4)
// Should be sorted by name (asc) first, then by value (desc, nulls first)
expect(queryResult.map((row: any) => row.name)).toEqual([
'A Item',
'M Item',
'X Item',
'Z Item',
])
// The first item (A Item) should have value 30
expect(queryResult[0].value).toBe(30)
// Check if the X Item (with null value) is before Z Item (non-null value)
// due to nullsFirst: true for the value sort
const xItemIndex = queryResult.findIndex((row: any) => row.name === 'X Item')
const zItemIndex = queryResult.findIndex((row: any) => row.name === 'Z Item')
expect(xItemIndex).toBeLessThan(zItemIndex)
})
withTestDatabase('should generate SQL for special/quoted column names', async (db) => {
// Create test table with quoted names and insert data
await db.executeQuery(`
CREATE TABLE "test sql spaces" (
id SERIAL PRIMARY KEY,
"user name" TEXT,
"column-with-dashes" TEXT,
"quoted""column" TEXT
);
-- Insert test data
INSERT INTO "test sql spaces" ("user name", "column-with-dashes", "quoted""column") VALUES
('User 1', 'Value 1', 'Quoted 1'),
('User 2', 'Value 2', 'Quoted 2');
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test sql spaces')
expect(testTable).toBeDefined()
// Generate SQL
const sql = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(
`
"with _base_query as (select * from public."test sql spaces" order by "test sql spaces".id asc nulls last limit 10 offset 0)
select id,case
when octet_length("user name"::text) > 10240
then left("user name"::text, 10240) || '...'
else "user name"::text
end as "user name",case
when octet_length("column-with-dashes"::text) > 10240
then left("column-with-dashes"::text, 10240) || '...'
else "column-with-dashes"::text
end as "column-with-dashes",case
when octet_length("quoted""column"::text) > 10240
then left("quoted""column"::text, 10240) || '...'
else "quoted""column"::text
end as "quoted""column" from _base_query;"
`
)
// E2E Test: Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult.length).toBe(2)
expect(queryResult.map((row: any) => row['user name'])).toEqual(['User 1', 'User 2'])
expect(queryResult.map((row: any) => row['column-with-dashes'])).toEqual([
'Value 1',
'Value 2',
])
expect(queryResult.map((row: any) => row['quoted"column'])).toEqual(['Quoted 1', 'Quoted 2'])
})
withTestDatabase('should generate SQL for tables with large text fields', async (db) => {
// Create test table with large text fields
await db.executeQuery(`
CREATE TABLE test_large_text (
id SERIAL PRIMARY KEY,
small_text VARCHAR(100),
large_text TEXT,
json_data JSONB
);
-- Insert test data including a large text field
INSERT INTO test_large_text (small_text, large_text, json_data) VALUES
('Small text', repeat('Lorem ipsum ', 100), '{"key": "value", "nested": {"data": true}}'),
('Another small text', repeat('Dolor sit amet ', 100), '{"array": [1, 2, 3], "bool": false}');
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_large_text')
expect(testTable).toBeDefined()
// Generate SQL
const sql = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(
`
"with _base_query as (select * from public.test_large_text order by test_large_text.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(small_text::text) > 10240
then left(small_text::text, 10240) || '...'
else small_text::text
end as small_text,case
when octet_length(large_text::text) > 10240
then left(large_text::text, 10240) || '...'
else large_text::text
end as large_text,case
when octet_length(json_data::text) > 10240
then left(json_data::text, 10240) || '...'
else json_data::text
end as json_data from _base_query;"
`
)
// E2E Test: Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult.length).toBe(2)
expect(queryResult.map((row: any) => row.small_text)).toEqual([
'Small text',
'Another small text',
])
expect(queryResult[0].large_text.startsWith('Lorem ipsum')).toBe(true)
expect(queryResult[1].large_text.startsWith('Dolor sit amet')).toBe(true)
expect(JSON.parse(queryResult[0].json_data)).toHaveProperty('key', 'value')
expect(JSON.parse(queryResult[1].json_data)).toHaveProperty('array')
})
withTestDatabase('should generate SQL with pagination', async (db) => {
// Create test table and insert multiple rows for pagination
await db.executeQuery(`
CREATE TABLE test_pagination (
id SERIAL PRIMARY KEY,
name TEXT
);
-- Insert 15 rows for pagination testing
INSERT INTO test_pagination (name)
SELECT 'Item ' || i FROM generate_series(1, 15) i;
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_pagination')
expect(testTable).toBeDefined()
// Generate SQL for page 1 (5 items)
const sql1 = getTableRowsSql({
table: testTable!,
page: 1,
limit: 5,
})
expect(sql1).toMatchInlineSnapshot(
`
"with _base_query as (select * from public.test_pagination order by test_pagination.id asc nulls last limit 5 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name from _base_query;"
`
)
const page1Result = await db.executeQuery(sql1)
expect(page1Result.length).toBe(5)
expect(page1Result.map((row: any) => row.name)).toEqual([
'Item 1',
'Item 2',
'Item 3',
'Item 4',
'Item 5',
])
const sql2 = getTableRowsSql({
table: testTable!,
page: 2,
limit: 5,
})
// Verify SQL generation for page 2
expect(sql2).toMatchInlineSnapshot(
`
"with _base_query as (select * from public.test_pagination order by test_pagination.id asc nulls last limit 5 offset 5)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name from _base_query;"
`
)
const page2Result = await db.executeQuery(sql2)
expect(page2Result.length).toBe(5)
expect(page2Result.map((row: any) => row.name)).toEqual([
'Item 6',
'Item 7',
'Item 8',
'Item 9',
'Item 10',
])
})
withTestDatabase('should generate SQL for view', async (db) => {
// Create table and view
await db.executeQuery(`
CREATE TABLE test_view_source (
id SERIAL PRIMARY KEY,
name TEXT,
active BOOLEAN
);
-- Insert test data
INSERT INTO test_view_source (name, active) VALUES
('Active Item 1', true),
('Inactive Item', false),
('Active Item 2', true);
-- Create view that only shows active items
CREATE VIEW test_sql_view AS
SELECT id, name, active FROM test_view_source WHERE active = true;
`)
// Get view metadata
const { sql: viewsSql, zod: viewsZod } = pgMeta.views.list()
const views = viewsZod.parse(await db.executeQuery(viewsSql))
const testView = views.find((view) => view.name === 'test_sql_view')
expect(testView).toBeDefined()
// Generate SQL
const sql = getTableRowsSql({
table: testView!,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(
`
"with _base_query as (select * from public.test_sql_view order by test_sql_view.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,active from _base_query;"
`
)
// E2E Test: Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult.length).toBe(2) // Only active items should be in the view
expect(queryResult.map((row: any) => row.name)).toEqual(['Active Item 1', 'Active Item 2'])
expect(queryResult.every((row: any) => row.active === true)).toBe(true)
})
withTestDatabase('should generate SQL for materialized view', async (db) => {
// Create table and materialized view
await db.executeQuery(`
CREATE TABLE test_mv_source (
id SERIAL PRIMARY KEY,
name TEXT,
value NUMERIC
);
-- Insert test data
INSERT INTO test_mv_source (name, value) VALUES
('Item 1', 10.5),
('Item 2', -5.25),
('Item 3', 20);
-- Create materialized view that only includes positive values
CREATE MATERIALIZED VIEW test_sql_mv AS
SELECT id, name, value FROM test_mv_source WHERE value > 0;
`)
// Get materialized view metadata
const { sql: mvSql, zod: mvZod } = pgMeta.materializedViews.list()
const materializedViews = mvZod.parse(await db.executeQuery(mvSql))
const testMv = materializedViews.find((mv) => mv.name === 'test_sql_mv')
expect(testMv).toBeDefined()
// Generate SQL
const sql = getTableRowsSql({
table: testMv!,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(
`
"with _base_query as (select * from public.test_sql_mv order by test_sql_mv.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,value from _base_query;"
`
)
// E2E Test: Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult.length).toBe(2) // Only items with positive values
expect(queryResult.map((row: any) => row.name)).toEqual(['Item 1', 'Item 3'])
expect(queryResult.every((row: any) => row.value > 0)).toBe(true)
})
withTestDatabase('should generate SQL for foreign table', async (db) => {
// Set up a foreign table with the file_fdw extension
await db.executeQuery(`
-- Create the extension if it doesn't exist
CREATE EXTENSION IF NOT EXISTS file_fdw;
-- Create a foreign server
DROP SERVER IF EXISTS file_server2 CASCADE;
CREATE SERVER file_server2 FOREIGN DATA WRAPPER file_fdw;
-- Create a table to export data from
CREATE TABLE source_for_foreign_test (
id SERIAL PRIMARY KEY,
name TEXT,
description TEXT
);
-- Insert test data
INSERT INTO source_for_foreign_test (name, description) VALUES
('Foreign Item 1', 'Description 1'),
('Foreign Item 2', 'Description 2');
-- Export to CSV for the foreign table
COPY source_for_foreign_test TO '/tmp/foreign_test2.csv' WITH (FORMAT csv, HEADER);
-- Create the foreign table
CREATE FOREIGN TABLE test_sql_foreign (
id INT,
name TEXT,
description TEXT
) SERVER file_server2
OPTIONS (filename '/tmp/foreign_test2.csv', format 'csv', header 'true');
`)
// Get foreign table metadata
const { sql: ftSql, zod: ftZod } = pgMeta.foreignTables.list()
const foreignTables = ftZod.parse(await db.executeQuery(ftSql))
const testFt = foreignTables.find((ft) => ft.name === 'test_sql_foreign')
expect(testFt).toBeDefined()
// Generate SQL
const sql = getTableRowsSql({
table: testFt!,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(
`
"with _base_query as (select * from public.test_sql_foreign order by test_sql_foreign.id asc nulls last limit 10 offset 0)
select id,case
when octet_length(name::text) > 10240
then left(name::text, 10240) || '...'
else name::text
end as name,case
when octet_length(description::text) > 10240
then left(description::text, 10240) || '...'
else description::text
end as description from _base_query;"
`
)
// E2E Test: Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult).toMatchInlineSnapshot(`
[
{
"description": "Description 1",
"id": 1,
"name": "Foreign Item 1",
},
{
"description": "Description 2",
"id": 2,
"name": "Foreign Item 2",
},
]
`)
})
})
withTestDatabase('should handle large multi-dimensional arrays correctly', async (db) => {
// Create test table with multi-dimensional arrays
await db.executeQuery(`
CREATE TABLE public.monitor_data (
subject_id TEXT,
"timestamp" TIMESTAMP[],
"PPG" FLOAT8[][],
"ACC" FLOAT8[][]
);
INSERT INTO public.monitor_data (subject_id, "timestamp", "PPG", "ACC")
VALUES (
'subject-1',
ARRAY['2024-01-01 00:00:00'::timestamp, '2024-01-02 00:00:00'::timestamp],
ARRAY[
[1.1, 1.2, 1.3, 1.4, 1.5, 1.6],
[2.1, 2.2, 2.3, 2.4, 2.5, 2.6],
[3.1, 3.2, 3.3, 3.4, 3.5, 3.6]
]::FLOAT8[][],
ARRAY[
[4.1, 4.2, 4.3, 4.4, 4.5, 4.6],
[5.1, 5.2, 5.3, 5.4, 5.5, 5.6],
[6.1, 6.2, 6.3, 6.4, 6.5, 6.6]
]::FLOAT8[][]
);
INSERT INTO public.monitor_data (subject_id, "timestamp", "PPG", "ACC")
VALUES (
'subject-large',
-- large 1D timestamp array (e.g., 1000 timestamps)
ARRAY(
SELECT generate_series('2024-01-01'::timestamp, '2024-01-01'::timestamp + interval '999 minutes', '1 minute')
),
-- large 2D float8 arrays (e.g., 1000 x 6)
ARRAY(
SELECT ARRAY[
random(), random(), random(), random(), random(), random()
]::float8[]
FROM generate_series(1, 1000)
)::float8[][],
ARRAY(
SELECT ARRAY[
random(), random(), random(), random(), random(), random()
]::float8[]
FROM generate_series(1, 1000)
)::float8[][]
);
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'monitor_data')
expect(testTable).toBeDefined()
// Generate SQL with default settings
const sql = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
})
// Verify SQL generation with snapshot
expect(sql).toMatchInlineSnapshot(`
"with _base_query as (select * from public.monitor_data order by monitor_data.subject_id asc nulls last limit 10 offset 0)
select case
when octet_length(subject_id::text) > 10240
then left(subject_id::text, 10240) || '...'
else subject_id::text
end as subject_id,
case
when octet_length("timestamp"::text) > 10240
then
case
when array_ndims("timestamp") = 1
then
(select array_cat("timestamp"[1:50]::text[], array['...']::text[]))::text[]
else
"timestamp"[1:50]::text[]
end
else "timestamp"::text[]
end
,
case
when octet_length("PPG"::text) > 10240
then
case
when array_ndims("PPG") = 1
then
(select array_cat("PPG"[1:50]::text[], array['...']::text[]))::text[]
else
"PPG"[1:50]::text[]
end
else "PPG"::text[]
end
,
case
when octet_length("ACC"::text) > 10240
then
case
when array_ndims("ACC") = 1
then
(select array_cat("ACC"[1:50]::text[], array['...']::text[]))::text[]
else
"ACC"[1:50]::text[]
end
else "ACC"::text[]
end
from _base_query;"
`)
// Execute the SQL and verify results
const queryResult = await db.executeQuery(sql)
expect(queryResult.length).toBe(2)
// Verify the first row (small arrays)
const smallRow = queryResult.find((row: any) => row.subject_id === 'subject-1')
expect(smallRow).toBeDefined()
expect(smallRow.timestamp).toHaveLength(2)
expect(smallRow.PPG).toHaveLength(3)
expect(smallRow.PPG[0]).toHaveLength(6)
expect(smallRow.ACC).toHaveLength(3)
expect(smallRow.ACC[0]).toHaveLength(6)
// Verify the second row (large arrays)
const largeRow = queryResult.find((row: any) => row.subject_id === 'subject-large')
expect(largeRow).toBeDefined()
expect(largeRow.timestamp).toHaveLength(51) // Has the extra '...' element
expect(largeRow.PPG).toHaveLength(50)
expect(largeRow.PPG[0]).toHaveLength(6)
expect(largeRow.ACC).toHaveLength(50)
expect(largeRow.ACC[0]).toHaveLength(6)
// Test with custom maxArraySize
const sqlWithCustomSize = getTableRowsSql({
table: testTable!,
page: 1,
limit: 10,
maxArraySize: 10,
})
const customSizeResult = await db.executeQuery(sqlWithCustomSize)
const largeRowCustom = customSizeResult.find((row: any) => row.subject_id === 'subject-large')
expect(largeRowCustom.timestamp).toHaveLength(11) // Has the extra '...' element
expect(largeRowCustom.PPG).toHaveLength(10) // multi-dimentional array are truncated
expect(largeRowCustom.ACC).toHaveLength(10)
})
})