Skip to content

Commit b17535f

Browse files
cevhericlaude
andauthored
fix(sql): quote schema-qualified table names per-segment (0.9.23) (#70)
* fix(sql): quote schema-qualified table names per-segment The dialect-aware quoteIdentifier treated a schema-qualified table name as a single identifier, so 'employees.department' became '"employees.department"' — which Postgres reads as one relation literally named with a dot, failing with a query error. Sidebar 'select top 100' on any schema-qualified table was broken. Add quoteQualifiedName(): split on '.', quote each segment only-when-needed, and rejoin. 'employees.department' stays unquoted; 'public.Order' becomes 'public."Order"'. Use it for table names in the query generators and the data profiler (columns still use single-identifier quoting). Verified end-to-end against a Neon Postgres database: the generated 'SELECT * FROM employees.department LIMIT 50;' runs, whereas the old quoted form failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(package): bump version to 0.9.23 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4f2376a commit b17535f

4 files changed

Lines changed: 40 additions & 6 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@libredb/studio",
3-
"version": "0.9.22",
3+
"version": "0.9.23",
44
"private": false,
55
"publishConfig": {
66
"access": "public"

src/app/api/db/profile/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getOrCreateProvider } from '@/lib/db/factory';
33
import { createErrorResponse } from '@/lib/api/errors';
44
import { resolveConnection } from '@/lib/seed/resolve-connection';
55
import { getSession } from '@/lib/auth';
6-
import { quoteIdentifier } from '@/lib/query-generators';
6+
import { quoteIdentifier, quoteQualifiedName } from '@/lib/query-generators';
77

88
export async function POST(req: NextRequest) {
99
try {
@@ -71,7 +71,7 @@ export async function POST(req: NextRequest) {
7171
}
7272

7373
// Quote the table once for the target dialect (mixed-case / special names)
74-
const safeTable = quoteIdentifier(tableName, capabilities);
74+
const safeTable = quoteQualifiedName(tableName, capabilities);
7575

7676
// Get total row count
7777
const countResult = await provider.query(`SELECT COUNT(*) as total FROM ${safeTable}`);

src/lib/query-generators.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ export function quoteIdentifier(name: string, capabilities: ProviderCapabilities
3535
return /^[a-z_][a-z0-9_$]*$/.test(name) ? name : `"${name.replaceAll('"', '""')}"`;
3636
}
3737

38+
/**
39+
* Quote a possibly schema-qualified name (e.g. `employees.department`) by quoting
40+
* each dotted segment independently. Quoting the whole string as one identifier
41+
* (`"employees.department"`) would make the database look for a single relation
42+
* literally named with a dot, which fails. Each segment is still quoted only when
43+
* needed, so `employees.department` stays unquoted and `public.Order` becomes
44+
* `public."Order"`.
45+
*/
46+
export function quoteQualifiedName(name: string, capabilities: ProviderCapabilities): string {
47+
if (capabilities.queryLanguage === 'json') return name;
48+
return name
49+
.split('.')
50+
.map((part) => quoteIdentifier(part, capabilities))
51+
.join('.');
52+
}
53+
3854
export function generateTableQuery(tableName: string, capabilities: ProviderCapabilities): string {
3955
if (capabilities.queryLanguage === 'json') {
4056
return JSON.stringify(
@@ -43,7 +59,7 @@ export function generateTableQuery(tableName: string, capabilities: ProviderCapa
4359
2
4460
);
4561
}
46-
const table = quoteIdentifier(tableName, capabilities);
62+
const table = quoteQualifiedName(tableName, capabilities);
4763
// Oracle
4864
if (capabilities.defaultPort === 1521) {
4965
return `SELECT * FROM ${table} FETCH FIRST 50 ROWS ONLY;`;
@@ -79,7 +95,7 @@ export function generateSelectQuery(
7995
2
8096
);
8197
}
82-
const table = quoteIdentifier(tableName, capabilities);
98+
const table = quoteQualifiedName(tableName, capabilities);
8399
const cols = columns.map((c) => ` ${quoteIdentifier(c.name, capabilities)}`).join(',\n') || ' *';
84100
// Oracle
85101
if (capabilities.defaultPort === 1521) {

tests/unit/lib/query-generators.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from 'bun:test';
2-
import { generateTableQuery, generateSelectQuery, shouldRefreshSchema, quoteIdentifier } from '@/lib/query-generators';
2+
import { generateTableQuery, generateSelectQuery, shouldRefreshSchema, quoteIdentifier, quoteQualifiedName } from '@/lib/query-generators';
33
import type { ProviderCapabilities } from '@/lib/db/types';
44
import type { ColumnSchema } from '@/lib/types';
55

@@ -153,6 +153,24 @@ describe('quoteIdentifier', () => {
153153
.toBe('SELECT * FROM "Customer" LIMIT 50;');
154154
});
155155

156+
test('schema-qualified names are quoted per-segment, not as one identifier', () => {
157+
// lowercase schema.table → no quotes (Postgres)
158+
expect(quoteQualifiedName('employees.department', makeCaps({ defaultPort: 5432 })))
159+
.toBe('employees.department');
160+
// mixed-case table in a schema → only the table segment is quoted
161+
expect(quoteQualifiedName('public.Order', makeCaps({ defaultPort: 5432 })))
162+
.toBe('public."Order"');
163+
// bare name (no dot) is unchanged
164+
expect(quoteQualifiedName('Customer', makeCaps({ defaultPort: 5432 })))
165+
.toBe('"Customer"');
166+
});
167+
168+
test('generateTableQuery on a schema-qualified table does NOT wrap the dot (regression)', () => {
169+
// Was producing the broken `"employees.department"`; must be `employees.department`.
170+
expect(generateTableQuery('employees.department', makeCaps({ defaultPort: 5432 })))
171+
.toBe('SELECT * FROM employees.department LIMIT 50;');
172+
});
173+
156174
test('generateSelectQuery quotes mixed-case table and columns (Postgres)', () => {
157175
const cols: ColumnSchema[] = [
158176
{ name: 'Id', type: 'integer', nullable: false, isPrimary: true },

0 commit comments

Comments
 (0)