From 90ae5c7cd10a4554099fbe32283b8c50c600dfd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Thu, 4 Jun 2026 18:07:37 +0700 Subject: [PATCH 1/3] Fix SQL schema selector execution --- Architecture/navigation-url-state.md | 3 + Architecture/sql-editor-intelligence.md | 6 + Architecture/sql-view.md | 6 + FEATURES.md | 1 + README.md | 11 +- data/adapter.ts | 8 + data/bff/bff-client.test.ts | 23 +++ data/bff/bff-client.ts | 5 + data/executor.ts | 8 + data/pglite/index.ts | 49 +++++- data/postgres-core/adapter.test.ts | 26 ++- data/postgres-core/adapter.ts | 10 +- data/postgres-core/search-path.test.ts | 24 +++ data/postgres-core/search-path.ts | 19 ++ data/postgres-core/sql-editor.adapter.test.ts | 38 ++++ data/postgresjs/index.test.ts | 77 +++++++- data/postgresjs/index.ts | 50 +++++- demo/ppg-dev/seed-database.ts | 69 ++++++++ demo/ppg-dev/server.ts | 8 +- demo/ppg-dev/sql-lint.ts | 10 +- ui/studio/Navigation.test.tsx | 64 ++++++- ui/studio/Navigation.tsx | 29 ++- ui/studio/views/sql/SqlView.test.tsx | 166 +++++++++++++----- ui/studio/views/sql/SqlView.tsx | 28 ++- ui/studio/views/sql/sql-lint-source.test.ts | 30 ++++ ui/studio/views/sql/sql-lint-source.ts | 4 +- 26 files changed, 688 insertions(+), 84 deletions(-) create mode 100644 data/postgres-core/search-path.test.ts create mode 100644 data/postgres-core/search-path.ts diff --git a/Architecture/navigation-url-state.md b/Architecture/navigation-url-state.md index 2dd63b4e..ba0279b7 100644 --- a/Architecture/navigation-url-state.md +++ b/Architecture/navigation-url-state.md @@ -112,6 +112,9 @@ Use two patterns only: - Imperative updates: `setViewParam`, `setSchemaParam`, `setTableParam`, `setStreamParam`, etc. On schema switch, code MUST also resolve and set a valid table for that schema (current behavior in `Navigation.SchemaSelector`). +Database view links in the Studio sidebar (`schema`, `queries`, `console`, and +`sql`) MUST preserve the active `schema` URL param so switching views does not +silently fall back to the adapter default schema. ## Context Boundary diff --git a/Architecture/sql-editor-intelligence.md b/Architecture/sql-editor-intelligence.md index c37d56ea..91c927c3 100644 --- a/Architecture/sql-editor-intelligence.md +++ b/Architecture/sql-editor-intelligence.md @@ -51,6 +51,9 @@ This architecture governs: - stale responses MUST be discarded via request id check - Diagnostics MUST be clamped to document bounds before returning to CodeMirror. - Empty SQL MUST short-circuit with no lint request. +- SQL editor language configuration and lint requests MUST use the active URL + schema as the default schema so completions and diagnostics match the schema + selector. - Table-level SQL filter pills MAY reuse the same adapter `sqlLint` transport, but they MUST wrap raw `WHERE` fragments in a full `SELECT ... WHERE ...` statement before linting. - SQL filter-pill linting MUST run asynchronously in the background and MUST NOT block the initial filter apply interaction. - Table-level SQL filter-pill lint diagnostics are advisory UI state only and MUST NOT mutate the already-applied URL filter after the pill has been saved. @@ -61,6 +64,8 @@ This architecture governs: - existing `customHeaders` - existing `customPayload` - Procedure name for linting is `sql-lint`. +- Lint request payloads MAY include `schema`; when present, the backend MUST + plan unqualified identifiers against that schema. - New SQL-editor surfaces MUST NOT bypass BFF auth propagation. - Executor contract: - `Executor` MAY expose `lintSql(details, options)` as an optimized lint transport. @@ -79,6 +84,7 @@ This architecture governs: - `statement_timeout = 1000ms` - `lock_timeout = 100ms` - `idle_in_transaction_session_timeout = 1000ms` +- transaction-local `search_path` when a selected schema is provided On Postgres errors, diagnostics MUST include mapped position and SQLSTATE when available. Timeout diagnostics (`57014`) MUST be rewritten to a user-facing lint-timeout message. For multi-statement SQL text, diagnostics MUST map statement-relative positions back to full-editor offsets. diff --git a/Architecture/sql-view.md b/Architecture/sql-view.md index 8766de9d..34b19510 100644 --- a/Architecture/sql-view.md +++ b/Architecture/sql-view.md @@ -53,6 +53,11 @@ SQL result visualization is governed by: - SQL execution target is cursor-aware: - single-statement editor text: execute that statement - multi-statement editor text: execute the top-level statement containing the cursor +- SQL execution MUST pass the active URL schema to `adapter.raw(...)` so + unqualified identifiers resolve against the schema selected in Studio. +- PostgreSQL raw execution MUST apply the active schema through a + transaction-local `search_path` and MUST NOT leak schema changes across + requests or connections. - The grid renders all rows returned by that execution. - "row(s) returned in Xms" MUST report client-observed request duration from the SQL request transport (BFF request timing) when available; it MUST NOT @@ -94,6 +99,7 @@ SQL result visualization is governed by: Changes to SQL view MUST include tests for: - query execution success and cancellation behavior +- query execution with an active non-public schema - read-only grid rendering (pin control present, sorting controls disabled) - absence of history UI - absence of pagination controls in SQL result grid diff --git a/FEATURES.md b/FEATURES.md index 8026ef26..5564964d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -208,6 +208,7 @@ Matching substrings are highlighted in the grid, and timeout errors explain that The SQL view uses a full CodeMirror editor with dialect-aware syntax highlighting and schema-aware autocomplete for schemas, tables, and columns. Autocomplete is built from live introspection metadata, so suggestions track the current database structure without manual refresh workflows. +The active schema selector is also used as the default SQL namespace, so unqualified queries like `select * from order_items` run and lint against the selected schema instead of always resolving through `public`. PostgreSQL, MySQL, and SQLite linting runs asynchronously through guarded parse/plan `EXPLAIN` paths and shows inline diagnostics while preserving the normal run/cancel query flow. The same lint transport also validates saved table-level SQL filter pills in the background, so Studio reuses one dialect-aware SQL validation path for both the SQL editor and advanced inline table filters. Keyboard execution supports `Cmd/Ctrl+Enter`, and in multi-statement scripts it runs only the top-level statement at the current cursor. diff --git a/README.md b/README.md index 5f1bc115..42442540 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,7 @@ type StudioBFFRequest = | { procedure: "query"; query: Query; + schema?: string; customPayload?: Record; } | { @@ -378,6 +379,7 @@ type StudioBFFRequest = | { procedure: "sql-lint"; sql: string; + schema?: string; schemaVersion?: string; customPayload?: Record; } @@ -419,10 +421,10 @@ type QueryInsightsResponse = ### Procedure Semantics -- `query`: execute one SQL statement. This is required for every Studio adapter. +- `query`: execute one SQL statement. This is required for every Studio adapter. Requests may include `schema`; when present, use it as the default namespace for unqualified identifiers. - `sequence`: execute exactly two queries in order. This is used by MySQL write flows that update first and refetch second. - `transaction`: execute an ordered list of queries inside one database transaction. This is the contract addition that enables atomic staged multi-row saves from the table editor. -- `sql-lint`: return parse/plan diagnostics for the SQL editor and SQL-backed filter pills. +- `sql-lint`: return parse/plan diagnostics for the SQL editor and SQL-backed filter pills. Requests may include `schema`; diagnostics should plan unqualified identifiers against that selected schema. - `query-insights`: return a live query snapshot for the optional `Queries` view. For `sequence`, the second query should only run if the first one succeeds. For `transaction`, the response result array must stay in the same order as `body.queries`. @@ -469,7 +471,9 @@ export async function handleStudioBff(request: Request): Promise { } if (payload.procedure === "query") { - const [error, result] = await executor.execute(payload.query); + const [error, result] = await executor.execute(payload.query, { + schema: payload.schema, + }); return Response.json([error ? serializeError(error) : null, result]); } @@ -513,6 +517,7 @@ export async function handleStudioBff(request: Request): Promise { } const [error, result] = await executor.lintSql({ + schema: payload.schema, schemaVersion: payload.schemaVersion, sql: payload.sql, }); diff --git a/data/adapter.ts b/data/adapter.ts index c1491a21..aeb74e41 100644 --- a/data/adapter.ts +++ b/data/adapter.ts @@ -380,6 +380,10 @@ export interface AdapterQueryResult { } export interface AdapterRawDetails { + /** + * Schema to use as the default namespace for unqualified identifiers. + */ + schema?: string; sql: string; } @@ -400,6 +404,10 @@ export interface AdapterSqlSchemaResult { } export interface AdapterSqlLintDetails { + /** + * Schema to use as the default namespace for unqualified identifiers. + */ + schema?: string; schemaVersion?: string; sql: string; } diff --git a/data/bff/bff-client.test.ts b/data/bff/bff-client.test.ts index 49396454..6943c180 100644 --- a/data/bff/bff-client.test.ts +++ b/data/bff/bff-client.test.ts @@ -74,6 +74,27 @@ describe("bff/bff-client", () => { expect(response).toStrictEqual([null, results]); }); + it("should send selected schema context when provided", async () => { + fetchFn.mockResolvedValueOnce( + new Response(JSON.stringify([null, results])), + ); + + const response = await client.execute(query, { + schema: "test_app", + }); + const latestFetchCall = fetchFn.mock.calls.at(-1); + + expect(latestFetchCall?.[0]).toBe(url); + expect(JSON.parse(latestFetchCall?.[1]?.body as string)).toStrictEqual({ + customPayload, + procedure: "query", + query, + schema: "test_app", + }); + + expect(response).toStrictEqual([null, results]); + }); + it("should return (not throw) an error if the request fails", async () => { const error = "Internal server error"; fetchFn.mockResolvedValueOnce(new Response(error, { status: 500 })); @@ -564,6 +585,7 @@ describe("bff/bff-client", () => { describe("lintSql", () => { const details = { + schema: "test_app", schemaVersion: "schema-abc123", sql: "select * from users", }; @@ -605,6 +627,7 @@ describe("bff/bff-client", () => { expect(JSON.parse(value as string)).toStrictEqual({ customPayload, procedure: "sql-lint", + schema: "test_app", schemaVersion: "schema-abc123", sql: "select * from users", }); diff --git a/data/bff/bff-client.ts b/data/bff/bff-client.ts index f0d15198..aa2149c2 100644 --- a/data/bff/bff-client.ts +++ b/data/bff/bff-client.ts @@ -281,6 +281,7 @@ export interface StudioBFFQueryRequest { customPayload?: Record; procedure: "query"; query: Query; + schema?: string; } export interface StudioBFFSequenceRequest { @@ -296,6 +297,7 @@ export interface StudioBFFTransactionRequest { } export interface StudioBFFSqlLintDetails { + schema?: string; schemaVersion?: string; sql: string; } @@ -308,6 +310,7 @@ export interface StudioBFFSqlLintResult { export interface StudioBFFSqlLintRequest { customPayload?: Record; procedure: "sql-lint"; + schema?: string; schemaVersion?: string; sql: string; } @@ -335,6 +338,7 @@ export function createStudioBFFClient( customPayload, procedure: "query", query, + schema: options?.schema, } satisfies StudioBFFQueryRequest), headers: { Accept: "application/json", @@ -516,6 +520,7 @@ export function createStudioBFFClient( body: JSON.stringify({ customPayload, procedure: "sql-lint", + schema: details.schema, schemaVersion: details.schemaVersion, sql: details.sql, } satisfies StudioBFFSqlLintRequest), diff --git a/data/executor.ts b/data/executor.ts index 598f9049..7bb700c0 100644 --- a/data/executor.ts +++ b/data/executor.ts @@ -36,9 +36,17 @@ export interface SequenceExecutor extends Executor { export interface ExecuteOptions { abortSignal?: AbortSignal; + /** + * Schema to use as the default namespace for unqualified identifiers. + */ + schema?: string; } export interface SqlLintDetails { + /** + * Schema to use as the default namespace for unqualified identifiers. + */ + schema?: string; schemaVersion?: string; sql: string; } diff --git a/data/pglite/index.ts b/data/pglite/index.ts index 1584a22f..ebf6b255 100644 --- a/data/pglite/index.ts +++ b/data/pglite/index.ts @@ -3,6 +3,7 @@ import type { PGlite } from "@electric-sql/pglite"; import type { Adapter } from "../adapter"; import { AbortError, type Executor, getAbortResult } from "../executor"; import { createPostgresAdapter } from "../postgres-core"; +import { createPostgresSearchPath } from "../postgres-core/search-path"; import { createLintDiagnosticsFromPostgresError, validateSqlForLint, @@ -33,7 +34,8 @@ export function createPGLiteExecutor( const { addDelay = 0, logging = false } = options ?? {}; const executeQuery: Executor["execute"] = async (query, options) => { - const { abortSignal } = options || {}; + const { abortSignal, schema } = options || {}; + const searchPath = createPostgresSearchPath(schema); let abort: (reason?: unknown) => void; const abortionPromise = new Promise((_, reject) => (abort = reject)); @@ -47,10 +49,35 @@ export function createPGLiteExecutor( const addedDelay = typeof addDelay === "function" ? addDelay(query) : addDelay; - const queryPGLite = () => - pglite.query(query.sql, query.parameters as never[], { - rowMode: "object", - }); + const queryPGLite = async () => { + if (!searchPath) { + return await pglite.query(query.sql, query.parameters as never[], { + rowMode: "object", + }); + } + + await pglite.query("BEGIN"); + + try { + await pglite.query( + "select set_config('search_path', $1, true)", + [searchPath], + { rowMode: "object" }, + ); + const result = await pglite.query( + query.sql, + query.parameters as never[], + { + rowMode: "object", + }, + ); + await pglite.query("COMMIT"); + return result; + } catch (error: unknown) { + await pglite.query("ROLLBACK").catch(() => undefined); + throw error; + } + }; const queryPGLitePossiblyDelayed = addedDelay > 0 @@ -117,9 +144,13 @@ export function createPGLiteExecutor( throw new AbortError(); } - const result = await pglite.query(query.sql, query.parameters as never[], { - rowMode: "object", - }); + const result = await pglite.query( + query.sql, + query.parameters as never[], + { + rowMode: "object", + }, + ); results.push(result.rows as never); } @@ -152,7 +183,7 @@ export function createPGLiteExecutor( for (const statement of validation.statements) { const [error] = await executeQuery( asQuery(`EXPLAIN (FORMAT JSON) ${statement.statement}`), - options, + { ...options, schema: details.schema }, ); if (!error) { diff --git a/data/postgres-core/adapter.test.ts b/data/postgres-core/adapter.test.ts index 1be4420b..bff47a2f 100644 --- a/data/postgres-core/adapter.test.ts +++ b/data/postgres-core/adapter.test.ts @@ -485,7 +485,31 @@ describe("postgres-core/adapter", () => { expect(error).toBeNull(); expect(result?.rowCount).toBe(2); expect(result?.rows).toEqual([{ one: 1 }, { one: 2 }]); - expect(result?.query.sql).toBe("select 1 as one union all select 2 as one"); + expect(result?.query.sql).toBe( + "select 1 as one union all select 2 as one", + ); + }); + + it("executes unqualified raw SQL against the selected schema", async () => { + await pglite.exec(` + insert into "zoo"."animals" ("name") + values ('otter') + `); + + const [error, result] = await adapter.raw( + { + schema: "zoo", + sql: "select name from animals order by id desc limit 1", + }, + { abortSignal: new AbortController().signal }, + ); + + expect(error).toBeNull(); + expect(result?.rowCount).toBe(1); + expect(result?.rows).toEqual([{ name: "otter" }]); + expect(result?.query.sql).toBe( + "select name from animals order by id desc limit 1", + ); }); it("returns adapter errors for invalid SQL", async () => { diff --git a/data/postgres-core/adapter.ts b/data/postgres-core/adapter.ts index 2f84b02b..60a9fe24 100644 --- a/data/postgres-core/adapter.ts +++ b/data/postgres-core/adapter.ts @@ -394,7 +394,10 @@ async function lintWithExplainFallback( const explainQuery = asQuery>( `EXPLAIN ${statement.statement}`, ); - const [error] = await executor.execute(explainQuery, options); + const [error] = await executor.execute(explainQuery, { + ...options, + schema: details.schema, + }); if (!error) { continue; @@ -434,7 +437,10 @@ async function executeRawQuery( ): Promise> { try { const query = asQuery>(details.sql); - const [error, rows] = await executor.execute(query, options); + const [error, rows] = await executor.execute(query, { + ...options, + schema: details.schema, + }); if (error) { return createAdapterError({ diff --git a/data/postgres-core/search-path.test.ts b/data/postgres-core/search-path.test.ts new file mode 100644 index 00000000..4f8849af --- /dev/null +++ b/data/postgres-core/search-path.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { createPostgresSearchPath } from "./search-path"; + +describe("postgres-core/search-path", () => { + it("returns null when no schema is provided", () => { + expect(createPostgresSearchPath(undefined)).toBeNull(); + expect(createPostgresSearchPath("")).toBeNull(); + }); + + it("uses public by itself for the public schema", () => { + expect(createPostgresSearchPath("public")).toBe('"public"'); + }); + + it("places the selected schema before public", () => { + expect(createPostgresSearchPath("test_app")).toBe('"test_app", public'); + }); + + it("quotes schema names for search_path parsing", () => { + expect(createPostgresSearchPath('tenant "one"')).toBe( + '"tenant ""one""", public', + ); + }); +}); diff --git a/data/postgres-core/search-path.ts b/data/postgres-core/search-path.ts new file mode 100644 index 00000000..ebb8a464 --- /dev/null +++ b/data/postgres-core/search-path.ts @@ -0,0 +1,19 @@ +export function createPostgresSearchPath( + schema: string | undefined, +): string | null { + if (schema === undefined || schema.length === 0) { + return null; + } + + const quotedSchema = quotePostgresSearchPathIdentifier(schema); + + if (schema === "public") { + return quotedSchema; + } + + return `${quotedSchema}, public`; +} + +function quotePostgresSearchPathIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} diff --git a/data/postgres-core/sql-editor.adapter.test.ts b/data/postgres-core/sql-editor.adapter.test.ts index 26603d03..d48bfe89 100644 --- a/data/postgres-core/sql-editor.adapter.test.ts +++ b/data/postgres-core/sql-editor.adapter.test.ts @@ -108,6 +108,44 @@ describe("postgres-core/adapter sql-editor support", () => { ); }); + it("delegates selected schema context to executor lintSql", async () => { + const lintResult: AdapterSqlLintResult = { + diagnostics: [], + schemaVersion: "schema-v1", + }; + const lintSql = vi.fn().mockResolvedValue([null, lintResult]); + const executorWithLint = { + ...createExecutor(), + lintSql, + } as Executor & { + lintSql: typeof lintSql; + }; + const adapter = createPostgresAdapter({ + executor: executorWithLint, + }); + const abortController = new AbortController(); + + const [error, result] = await adapter.sqlLint!( + { + schema: "test_app", + schemaVersion: "schema-v1", + sql: "select * from order_items", + }, + { abortSignal: abortController.signal }, + ); + + expect(error).toBeNull(); + expect(result).toEqual(lintResult); + expect(lintSql).toHaveBeenCalledWith( + { + schema: "test_app", + schemaVersion: "schema-v1", + sql: "select * from order_items", + }, + { abortSignal: abortController.signal }, + ); + }); + it("falls back to EXPLAIN linting when executor has no lintSql method", async () => { const adapter = createPostgresAdapter({ executor: createExecutor(), diff --git a/data/postgresjs/index.test.ts b/data/postgresjs/index.test.ts index 2f58e4e2..24330f4f 100644 --- a/data/postgresjs/index.test.ts +++ b/data/postgresjs/index.test.ts @@ -5,7 +5,9 @@ import { createPostgresJSExecutor } from "./index"; function createPostgresJsMock(args?: { beginImpl?: ( - callback: (tx: { unsafe: (sql: string) => Promise }) => unknown, + callback: (tx: { + unsafe: (sql: string, parameters?: unknown) => Promise; + }) => unknown, ) => Promise; }): { begin: ReturnType; @@ -16,7 +18,9 @@ function createPostgresJsMock(args?: { const beginImpl = args?.beginImpl ?? (async ( - callback: (tx: { unsafe: (sql: string) => Promise }) => unknown, + callback: (tx: { + unsafe: (sql: string, parameters?: unknown) => Promise; + }) => unknown, ) => { await callback({ unsafe: vi.fn().mockResolvedValue([]), @@ -140,6 +144,46 @@ describe("postgresjs executor", () => { }); }); + it("executes queries with a transaction-local search path when schema is provided", async () => { + const result = [{ id: 1 }]; + const txUnsafe = vi.fn((sql: string) => { + if (sql.startsWith("select set_config")) { + return Promise.resolve([]); + } + + return Promise.resolve(result); + }); + const { begin, postgresjs, unsafe } = createPostgresJsMock({ + beginImpl: (callback) => Promise.resolve(callback({ unsafe: txUnsafe })), + }); + const executor = createPostgresJSExecutor(postgresjs); + + const [error, rows] = await executor.execute( + { + parameters: [], + sql: "select * from order_items", + }, + { + schema: "test_app", + }, + ); + + expect(error).toBeNull(); + expect(rows).toEqual(result); + expect(begin).toHaveBeenCalledTimes(1); + expect(unsafe).not.toHaveBeenCalled(); + expect(txUnsafe).toHaveBeenNthCalledWith( + 1, + "select set_config('search_path', $1, true)", + ['"test_app", public'], + ); + expect(txUnsafe).toHaveBeenNthCalledWith( + 2, + "select * from order_items", + [], + ); + }); + it("returns validation diagnostics without touching the database for invalid SQL", async () => { const { begin, postgresjs } = createPostgresJsMock(); const executor = createPostgresJSExecutor(postgresjs); @@ -187,6 +231,35 @@ describe("postgresjs executor", () => { expect(txUnsafe).toHaveBeenCalledWith("EXPLAIN (FORMAT JSON) select 1"); }); + it("runs lint query with selected schema search path", async () => { + const txUnsafe = vi.fn().mockResolvedValue([]); + const { postgresjs } = createPostgresJsMock({ + beginImpl: async (callback) => { + await callback({ unsafe: txUnsafe }); + }, + }); + const executor = createPostgresJSExecutor(postgresjs); + + if (!executor.lintSql) { + throw new Error("Expected postgresjs executor to expose lintSql"); + } + + const [error, result] = await executor.lintSql({ + schema: "test_app", + sql: "select * from order_items;", + }); + + expect(error).toBeNull(); + expect(result?.diagnostics).toEqual([]); + expect(txUnsafe).toHaveBeenCalledWith( + "select set_config('search_path', $1, true)", + ['"test_app", public'], + ); + expect(txUnsafe).toHaveBeenCalledWith( + "EXPLAIN (FORMAT JSON) select * from order_items", + ); + }); + it("maps postgres errors to lint diagnostics", async () => { const txUnsafe = vi.fn((sql: string) => { if (sql.startsWith("EXPLAIN")) { diff --git a/data/postgresjs/index.ts b/data/postgresjs/index.ts index 1d8b04b5..80e0d7cd 100644 --- a/data/postgresjs/index.ts +++ b/data/postgresjs/index.ts @@ -1,6 +1,7 @@ import type { ReservedSql, Sql } from "postgres"; import { AbortError, type Executor, getAbortResult } from "../executor"; +import { createPostgresSearchPath } from "../postgres-core/search-path"; import { createLintDiagnosticsFromPostgresError, validateSqlForLint, @@ -21,14 +22,17 @@ type TemporalColumnKind = "date" | "timestamp"; export function createPostgresJSExecutor(postgresjs: Sql): Executor { return { execute: async (query, options) => { - const { abortSignal } = options || {}; + const { abortSignal, schema } = options || {}; + const searchPath = createPostgresSearchPath(schema); if (!abortSignal) { try { - const result = await postgresjs.unsafe( - query.sql, - query.parameters as never, - ); + const result = searchPath + ? await postgresjs.begin(async (tx) => { + await setPostgresSearchPath(tx, searchPath); + return await tx.unsafe(query.sql, query.parameters as never); + }) + : await postgresjs.unsafe(query.sql, query.parameters as never); return [null, normalizeTemporalResult(result as never)]; } catch (error: unknown) { @@ -42,6 +46,7 @@ export function createPostgresJSExecutor(postgresjs: Sql): Executor { let abortListener: (() => void) | undefined; let connection: ReservedSql | undefined; + let transactionStarted = false; try { let aborted: () => void; @@ -91,6 +96,12 @@ export function createPostgresJSExecutor(postgresjs: Sql): Executor { return getAbortResult(); } + if (searchPath) { + await connection.unsafe("BEGIN"); + transactionStarted = true; + await setPostgresSearchPath(connection, searchPath); + } + const queryPromise = connection.unsafe( query.sql, query.parameters as never, @@ -102,15 +113,29 @@ export function createPostgresJSExecutor(postgresjs: Sql): Executor { void Promise.allSettled([ cancelQuery(postgresjs, pidResult!), queryPromise, - ]).finally(() => connection?.release()); + ]) + .then(async () => { + if (transactionStarted) { + await connection?.unsafe("ROLLBACK").catch(() => undefined); + } + }) + .finally(() => connection?.release()); return getAbortResult(); } + if (transactionStarted) { + await connection.unsafe("COMMIT"); + transactionStarted = false; + } + connection.release(); return [null, normalizeTemporalResult(queryResult as never)]; } catch (error: unknown) { + if (transactionStarted) { + await connection?.unsafe("ROLLBACK").catch(() => undefined); + } connection?.release(); return [error as Error]; @@ -155,6 +180,7 @@ export function createPostgresJSExecutor(postgresjs: Sql): Executor { }, async lintSql(details, options) { + const searchPath = createPostgresSearchPath(details.schema); const validation = validateSqlForLint(details.sql); if (!validation.ok) { @@ -178,6 +204,9 @@ export function createPostgresJSExecutor(postgresjs: Sql): Executor { await tx.unsafe( `set local idle_in_transaction_session_timeout = '${SQL_LINT_IDLE_IN_TRANSACTION_TIMEOUT}'`, ); + if (searchPath) { + await setPostgresSearchPath(tx, searchPath); + } await tx.unsafe(`EXPLAIN (FORMAT JSON) ${statementSql}`); }); }; @@ -261,6 +290,15 @@ export function createPostgresJSExecutor(postgresjs: Sql): Executor { }; } +async function setPostgresSearchPath( + client: Pick, + searchPath: string, +): Promise { + await client.unsafe("select set_config('search_path', $1, true)", [ + searchPath, + ] as never); +} + function normalizeTemporalResult( result: QueryResult>, ): QueryResult> { diff --git a/demo/ppg-dev/seed-database.ts b/demo/ppg-dev/seed-database.ts index d3208e11..7801a97e 100644 --- a/demo/ppg-dev/seed-database.ts +++ b/demo/ppg-dev/seed-database.ts @@ -49,6 +49,10 @@ export async function seedDatabase(connectionString: string): Promise { $$; `); + await transaction.unsafe(` + create schema if not exists test_app + `); + await transaction.unsafe(` create table if not exists organizations ( id text primary key, @@ -166,8 +170,47 @@ export async function seedDatabase(connectionString: string): Promise { ) `); + await transaction.unsafe(` + create table if not exists test_app.customers ( + id integer primary key, + name text not null, + email text not null unique + ) + `); + + await transaction.unsafe(` + create table if not exists test_app.products ( + id integer primary key, + name text not null, + sku text not null unique, + price numeric(10, 2) not null + ) + `); + + await transaction.unsafe(` + create table if not exists test_app.orders ( + id integer primary key, + customer_id integer not null references test_app.customers(id) on delete cascade, + status text not null check (status in ('pending', 'paid', 'shipped')), + ordered_at timestamptz not null default now() + ) + `); + + await transaction.unsafe(` + create table if not exists test_app.order_items ( + id integer primary key, + order_id integer not null references test_app.orders(id) on delete cascade, + product_id integer not null references test_app.products(id) on delete restrict, + quantity integer not null check (quantity > 0) + ) + `); + await transaction.unsafe(` truncate table + test_app.order_items, + test_app.orders, + test_app.products, + test_app.customers, all_data_types, incidents, feature_flags, @@ -200,6 +243,32 @@ export async function seedDatabase(connectionString: string): Promise { ])} `; + await transaction.unsafe(` + insert into test_app.customers (id, name, email) values + (1, 'Acme Supply', 'ops@acme.example'), + (2, 'Northwind Labs', 'data@northwind.example') + `); + + await transaction.unsafe(` + insert into test_app.products (id, name, sku, price) values + (1, 'Query Analyzer', 'QA-001', 149.00), + (2, 'Latency Monitor', 'LM-002', 89.00), + (3, 'Schema Mapper', 'SM-003', 59.00) + `); + + await transaction.unsafe(` + insert into test_app.orders (id, customer_id, status, ordered_at) values + (1, 1, 'paid', now() - interval '3 days'), + (2, 2, 'pending', now() - interval '1 day') + `); + + await transaction.unsafe(` + insert into test_app.order_items (id, order_id, product_id, quantity) values + (1, 1, 1, 2), + (2, 1, 2, 1), + (3, 2, 3, 4) + `); + await transaction.unsafe(` insert into incidents ( id, diff --git a/demo/ppg-dev/server.ts b/demo/ppg-dev/server.ts index 1f6e27e6..9a9e6e41 100644 --- a/demo/ppg-dev/server.ts +++ b/demo/ppg-dev/server.ts @@ -475,7 +475,9 @@ async function handleBffQueryRequest(request: Request): Promise { if (payload.procedure === "query") { const [error, result] = await runSerializedQuery(() => - executeAndRecordQuery(executor, payload.query), + executeAndRecordQuery(executor, payload.query, { + schema: payload.schema, + }), ); return Response.json([error ? serializeError(error) : null, result]); @@ -568,6 +570,7 @@ async function handleBffQueryRequest(request: Request): Promise { const result = await runSerializedQuery(() => lintPostgresSql({ postgresClient: lintPostgresClient, + schema: payload.schema, schemaVersion: payload.schemaVersion, sql: payload.sql, }), @@ -585,9 +588,10 @@ async function handleBffQueryRequest(request: Request): Promise { async function executeAndRecordQuery( executor: PostgresExecutor, query: Query, + options?: { schema?: string }, ): Promise>> { const startedAt = performance.now(); - const result = await executor.execute(query); + const result = await executor.execute(query, { schema: options?.schema }); const durationMs = Math.max(0, performance.now() - startedAt); const [error, rows] = result; diff --git a/demo/ppg-dev/sql-lint.ts b/demo/ppg-dev/sql-lint.ts index a4623284..b9b161d5 100644 --- a/demo/ppg-dev/sql-lint.ts +++ b/demo/ppg-dev/sql-lint.ts @@ -1,6 +1,7 @@ import type { Sql } from "postgres"; import type { StudioBFFSqlLintResult } from "../../data/bff"; +import { createPostgresSearchPath } from "../../data/postgres-core/search-path"; import { createLintDiagnosticsFromPostgresError, validateSqlForLint, @@ -12,10 +13,12 @@ const SQL_LINT_IDLE_IN_TRANSACTION_TIMEOUT = "1000ms"; export async function lintPostgresSql(args: { postgresClient: Sql; + schema?: string; schemaVersion?: string; sql: string; }): Promise { - const { postgresClient, schemaVersion, sql } = args; + const { postgresClient, schema, schemaVersion, sql } = args; + const searchPath = createPostgresSearchPath(schema); const validation = validateSqlForLint(sql); if (!validation.ok) { @@ -37,6 +40,11 @@ export async function lintPostgresSql(args: { await tx.unsafe( `set local idle_in_transaction_session_timeout = '${SQL_LINT_IDLE_IN_TRANSACTION_TIMEOUT}'`, ); + if (searchPath) { + await tx.unsafe("select set_config('search_path', $1, true)", [ + searchPath, + ] as never); + } await tx.unsafe(`EXPLAIN (FORMAT JSON) ${statement.statement}`); }); } catch (error: unknown) { diff --git a/ui/studio/Navigation.test.tsx b/ui/studio/Navigation.test.tsx index 7555b414..373c86e9 100644 --- a/ui/studio/Navigation.test.tsx +++ b/ui/studio/Navigation.test.tsx @@ -541,7 +541,9 @@ describe("Navigation", () => { (link) => link.textContent?.trim() === "Queries", ); - expect(queriesLink?.getAttribute("href")).toBe("#viewParam=queries"); + expect(queriesLink?.getAttribute("href")).toBe( + "#schemaParam=public&viewParam=queries", + ); expect(queriesLink?.getAttribute("data-active")).toBe("true"); act(() => { @@ -550,6 +552,66 @@ describe("Navigation", () => { container.remove(); }); + it("preserves selected schema when navigating between Studio views", () => { + useStudioMock.mockImplementation(() => ({ + hasDatabase: true, + hasQueryInsights: true, + isDarkMode, + navigationWidth: 192, + setNavigationWidth: setNavigationWidthMock, + })); + useNavigationMock.mockReturnValue({ + createUrl(values: Record) { + return `#${Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join("&")}`; + }, + metadata: { + activeTable: { name: "order_items", schema: "test_app" }, + isFetching: false, + }, + schemaParam: "test_app", + setSchemaParam: vi.fn(() => Promise.resolve(new URLSearchParams())), + setTableParam: vi.fn(() => Promise.resolve(new URLSearchParams())), + streamParam: null, + viewParam: "sql", + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const studioLinksByText = new Map( + Array.from( + container.querySelectorAll( + 'nav[aria-label="Studio"] a', + ), + ).map((link) => [link.textContent?.trim(), link.getAttribute("href")]), + ); + + expect(studioLinksByText.get("Visualizer")).toBe( + "#schemaParam=test_app&viewParam=schema", + ); + expect(studioLinksByText.get("Queries")).toBe( + "#schemaParam=test_app&viewParam=queries", + ); + expect(studioLinksByText.get("Console")).toBe( + "#schemaParam=test_app&viewParam=console", + ); + expect(studioLinksByText.get("SQL")).toBe( + "#schemaParam=test_app&viewParam=sql", + ); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("closes table search on blur when the search input is empty", () => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/studio/Navigation.tsx b/ui/studio/Navigation.tsx index a107cb8a..900a637e 100644 --- a/ui/studio/Navigation.tsx +++ b/ui/studio/Navigation.tsx @@ -518,7 +518,13 @@ export function Navigation({ className }: NavigationProps) { isActive={viewParam === "schema"} className={navigationItemClasses} > - + Visualizer @@ -529,7 +535,10 @@ export function Navigation({ className }: NavigationProps) { className={navigationItemClasses} > Queries @@ -541,7 +550,13 @@ export function Navigation({ className }: NavigationProps) { isActive={viewParam === "console"} className={navigationItemClasses} > - + Console @@ -550,7 +565,13 @@ export function Navigation({ className }: NavigationProps) { isActive={viewParam === "sql"} className={navigationItemClasses} > - + SQL diff --git a/ui/studio/views/sql/SqlView.test.tsx b/ui/studio/views/sql/SqlView.test.tsx index 74197fd4..0be8ad68 100644 --- a/ui/studio/views/sql/SqlView.test.tsx +++ b/ui/studio/views/sql/SqlView.test.tsx @@ -12,7 +12,7 @@ import { SqlView } from "./SqlView"; type StudioMock = ReturnType; const useStudioMock = vi.fn<() => StudioMock>(); -const useNavigationMock = vi.fn(); +const useNavigationMock = vi.fn<() => { schemaParam: string }>(); const setPinnedColumnIdsMock = vi.fn(); let mockEditorCursorHead = 0; let mockEditorDocLength = 0; @@ -330,16 +330,16 @@ function createStudioMock(adapter: Adapter): { } function setInputValue(element: HTMLInputElement, value: string) { - const setter = Object.getOwnPropertyDescriptor( + const descriptor = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", - )?.set; + ); - if (!setter) { + if (!descriptor?.set) { throw new Error("Input value setter is unavailable"); } - setter.call(element, value); + descriptor.set.call(element, value); element.dispatchEvent(new Event("input", { bubbles: true })); } @@ -471,16 +471,56 @@ describe("SqlView", () => { harness.cleanup(); }); + it("executes unqualified SQL against the selected schema", async () => { + useNavigationMock.mockReturnValue({ + schemaParam: "test_app", + }); + const { adapter, rawSpy } = createAdapterMock(); + const studio = createStudioMock(adapter); + useStudioMock.mockReturnValue(studio); + + const harness = renderSqlView(); + const runButton = [...harness.container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Run SQL"), + ); + + if (!runButton) { + throw new Error("SQL view run control not rendered"); + } + + act(() => { + mockCodeMirrorOnChange?.("select * from order_items"); + }); + + act(() => { + runButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => { + return ( + harness.container.textContent?.includes("1 row(s) returned") ?? false + ); + }); + + expect(rawSpy).toHaveBeenCalledWith( + { schema: "test_app", sql: "select * from order_items" }, + expect.any(Object), + ); + + harness.cleanup(); + }); + it("generates SQL, focuses the editor, and waits for a manual run", async () => { const { adapter, rawSpy } = createAdapterMock(); const studio = createStudioMock(adapter); - const llmMock = vi.fn<(request: StudioLlmRequest) => Promise>( - async () => + const llmMock = vi.fn<(request: StudioLlmRequest) => Promise>(() => + Promise.resolve( JSON.stringify({ rationale: "Matched the organizations table.", sql: "select * from public.organizations limit 5;", shouldGenerateVisualization: false, }), + ), ); studio.llm = llmMock; useStudioMock.mockReturnValue(studio); @@ -551,15 +591,17 @@ describe("SqlView", () => { }); expect(rawSpy).toHaveBeenCalledWith( - { sql: "select * from public.organizations limit 5" }, + { schema: "public", sql: "select * from public.organizations limit 5" }, expect.any(Object), ); expect(llmMock).toHaveBeenCalledWith( expect.objectContaining({ - prompt: expect.stringContaining('"shouldGenerateVisualization":true'), task: "sql-generation", }), ); + expect(llmMock.mock.calls[0]?.[0].prompt).toContain( + '"shouldGenerateVisualization":true', + ); const summary = harness.container.querySelector( '[data-testid="sql-result-summary"]', ); @@ -651,12 +693,12 @@ describe("SqlView", () => { expect(sqlLintMock).toHaveBeenCalledTimes(2); expect(sqlLintMock).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ sql: invalidSql }), + expect.objectContaining({ schema: "public", sql: invalidSql }), expect.any(Object), ); expect(sqlLintMock).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ sql: correctedSql }), + expect.objectContaining({ schema: "public", sql: correctedSql }), expect.any(Object), ); expect(llmMock).toHaveBeenCalledTimes(2); @@ -852,10 +894,12 @@ describe("SqlView", () => { expect(llmMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - prompt: expect.stringContaining("Bklit chart components"), task: "sql-visualization", }), ); + expect(llmMock.mock.calls[1]?.[0].prompt).toContain( + "Bklit chart components", + ); expect(llmMock.mock.calls[1]?.[0].prompt).toContain("SQL: select 1 as one"); expect(llmMock.mock.calls[1]?.[0].prompt).toContain( "AI query request: show me organizations", @@ -875,12 +919,12 @@ describe("SqlView", () => { it("feeds AI-generated SQL execution errors back into the model without auto-running the correction", async () => { const badSql = "select typeof(json_col) from public.organizations limit 5;"; const correctedSql = "select id from public.organizations limit 5;"; - const raw: Adapter["raw"] = async (details) => { + const raw: Adapter["raw"] = (details) => { const error = new Error( "function typeof(json) does not exist", ) as AdapterError; error.query = { parameters: [], sql: details.sql }; - return [error]; + return Promise.resolve([error]); }; const { adapter, rawSpy } = createAdapterMock({ raw }); const studio = createStudioMock(adapter); @@ -958,7 +1002,10 @@ describe("SqlView", () => { ); expect(rawSpy).toHaveBeenCalledTimes(1); expect(rawSpy).toHaveBeenCalledWith( - { sql: "select typeof(json_col) from public.organizations limit 5" }, + { + schema: "public", + sql: "select typeof(json_col) from public.organizations limit 5", + }, expect.any(Object), ); expect(harness.container.textContent).toContain( @@ -970,12 +1017,12 @@ describe("SqlView", () => { }); it("keeps normal manual SQL execution errors inline without asking AI for a correction", async () => { - const raw: Adapter["raw"] = async (details) => { + const raw: Adapter["raw"] = (details) => { const error = new Error( "relation missing_table does not exist", ) as AdapterError; error.query = { parameters: [], sql: details.sql }; - return [error]; + return Promise.resolve([error]); }; const { adapter, rawSpy } = createAdapterMock({ raw }); const studio = createStudioMock(adapter); @@ -1111,12 +1158,14 @@ describe("SqlView", () => { it("persists generated AI SQL prompts in the local SQL editor collection", async () => { const { adapter } = createAdapterMock(); const studio = createStudioMock(adapter); - studio.llm = vi.fn(async () => - JSON.stringify({ - rationale: "Matched the organizations table.", - sql: "select * from public.organizations limit 5;", - shouldGenerateVisualization: false, - }), + studio.llm = vi.fn(() => + Promise.resolve( + JSON.stringify({ + rationale: "Matched the organizations table.", + sql: "select * from public.organizations limit 5;", + shouldGenerateVisualization: false, + }), + ), ); useStudioMock.mockReturnValue(studio); @@ -1188,15 +1237,17 @@ describe("SqlView", () => { harness.cleanup(); }); - it("cycles AI prompt history as placeholder text and materializes it on keypress or click", async () => { + it("cycles AI prompt history as placeholder text and materializes it on keypress or click", () => { const { adapter } = createAdapterMock(); const studio = createStudioMock(adapter); - studio.llm = vi.fn(async () => - JSON.stringify({ - rationale: "Matched the organizations table.", - sql: "select * from public.organizations limit 5;", - shouldGenerateVisualization: false, - }), + studio.llm = vi.fn(() => + Promise.resolve( + JSON.stringify({ + rationale: "Matched the organizations table.", + sql: "select * from public.organizations limit 5;", + shouldGenerateVisualization: false, + }), + ), ); window.localStorage.setItem( "prisma-studio-sql-editor-state-v1", @@ -1271,9 +1322,9 @@ describe("SqlView", () => { it("renders AI SQL generation errors inline", async () => { const { adapter } = createAdapterMock(); const studio = createStudioMock(adapter); - studio.llm = vi.fn(async () => { - throw new Error("AI SQL generation exploded."); - }); + studio.llm = vi.fn(() => + Promise.reject(new Error("AI SQL generation exploded.")), + ); useStudioMock.mockReturnValue(studio); const harness = renderSqlView(); @@ -1304,14 +1355,18 @@ describe("SqlView", () => { ); }); + expect(harness.container.textContent).toContain( + "AI SQL generation exploded.", + ); + harness.cleanup(); }); it("renders an in-grid graph action and swaps it for a chart after generation", async () => { const { adapter } = createAdapterMock(); const studio = createStudioMock(adapter); - const llmMock = vi.fn<(request: StudioLlmRequest) => Promise>( - async () => + const llmMock = vi.fn<(request: StudioLlmRequest) => Promise>(() => + Promise.resolve( JSON.stringify({ config: { data: [{ label: "organizations", rows: 1 }], @@ -1320,6 +1375,7 @@ describe("SqlView", () => { xKey: "label", }, }), + ), ); studio.llm = llmMock; useStudioMock.mockReturnValue(studio); @@ -1446,15 +1502,17 @@ describe("SqlView", () => { }; const { adapter } = createAdapterMock({ raw }); const studio = createStudioMock(adapter); - studio.llm = vi.fn(async () => - JSON.stringify({ - config: { - data: [{ label: "organizations", rows: 1 }], - series: [{ key: "rows", label: "Rows" }], - type: "bar", - xKey: "label", - }, - }), + studio.llm = vi.fn(() => + Promise.resolve( + JSON.stringify({ + config: { + data: [{ label: "organizations", rows: 1 }], + series: [{ key: "rows", label: "Rows" }], + type: "bar", + xKey: "label", + }, + }), + ), ); useStudioMock.mockReturnValue(studio); @@ -1516,6 +1574,22 @@ describe("SqlView", () => { ); }); + expect( + [...harness.container.querySelectorAll("button")].some((button) => + button.textContent?.includes("Cancel"), + ), + ).toBe(true); + expect( + harness.container.querySelector( + '[data-testid="sql-result-visualization-chart"]', + ), + ).toBeNull(); + expect( + harness.container + .querySelector('[data-testid="sql-result-visualization-action"]') + ?.textContent?.includes("Visualize data with AI"), + ).toBe(true); + await act(async () => { secondQueryDeferred.resolve([ null, @@ -1557,7 +1631,7 @@ describe("SqlView", () => { }); expect(rawSpy).toHaveBeenCalledWith( - { sql: "select * from" }, + { schema: "public", sql: "select * from" }, expect.any(Object), ); const [firstRawCallArgs] = rawSpy.mock.calls; @@ -1701,7 +1775,7 @@ describe("SqlView", () => { await waitFor(() => rawSpy.mock.calls.length > 0); expect(rawSpy).toHaveBeenCalledWith( - { sql: "select 2 as two" }, + { schema: "public", sql: "select 2 as two" }, expect.any(Object), ); diff --git a/ui/studio/views/sql/SqlView.tsx b/ui/studio/views/sql/SqlView.tsx index 56c12540..219f92df 100644 --- a/ui/studio/views/sql/SqlView.tsx +++ b/ui/studio/views/sql/SqlView.tsx @@ -481,13 +481,17 @@ export function SqlView(_props: ViewProps) { }; }, [persistEditorDraft]); + const activeSqlSchema = getActiveSqlSchema({ + adapterDefaultSchema: adapter.defaultSchema, + schemaParam, + }); const sqlEditorSchema = useMemo(() => { return createSqlEditorSchemaFromIntrospection({ - defaultSchema: adapter.defaultSchema, + defaultSchema: activeSqlSchema, dialect: adapter.capabilities?.sqlDialect ?? "postgresql", introspection, }); - }, [adapter.capabilities?.sqlDialect, adapter.defaultSchema, introspection]); + }, [adapter.capabilities?.sqlDialect, activeSqlSchema, introspection]); const sqlEditorNamespace = useMemo(() => { return toCodeMirrorSqlNamespace(sqlEditorSchema.namespace); }, [sqlEditorSchema.namespace]); @@ -511,9 +515,10 @@ export function SqlView(_props: ViewProps) { return createSqlLintSource({ lintSql: (details, options) => adapter.sqlLint(details, options), + schema: activeSqlSchema, schemaVersion: sqlEditorSchema.version, }); - }, [adapter, sqlEditorSchema.version]); + }, [activeSqlSchema, adapter, sqlEditorSchema.version]); useEffect(() => { return () => { @@ -611,7 +616,7 @@ export function SqlView(_props: ViewProps) { } let generation = await resolveAiSqlGeneration({ - activeSchema: schemaParam ?? adapter.defaultSchema ?? "public", + activeSchema: activeSqlSchema, requestAiSqlGeneration, dialect: adapter.capabilities?.sqlDialect ?? "postgresql", introspection: generationIntrospection, @@ -640,7 +645,7 @@ export function SqlView(_props: ViewProps) { } generation = await resolveAiSqlGeneration({ - activeSchema: schemaParam ?? adapter.defaultSchema ?? "public", + activeSchema: activeSqlSchema, requestAiSqlGeneration, dialect: adapter.capabilities?.sqlDialect ?? "postgresql", introspection: generationIntrospection, @@ -667,6 +672,7 @@ export function SqlView(_props: ViewProps) { sqlValidationAbortControllerRef.current = abortController; const [error, result] = await adapter.sqlLint( { + schema: activeSqlSchema, schemaVersion: sqlEditorSchema.version, sql, }, @@ -718,7 +724,10 @@ export function SqlView(_props: ViewProps) { setVisualizationResetKey((currentValue) => currentValue + 1); const [error, rawResult] = await adapter.raw( - { sql }, + { + schema: activeSqlSchema, + sql, + }, { abortSignal: abortController.signal }, ); @@ -1356,6 +1365,13 @@ function adapterSupportsSqlLint(adapter: Adapter): adapter is Adapter & { return typeof adapter.sqlLint === "function"; } +function getActiveSqlSchema(args: { + adapterDefaultSchema?: string; + schemaParam: string | null | undefined; +}): string { + return args.schemaParam ?? args.adapterDefaultSchema ?? "public"; +} + function formatSqlLintDiagnosticForAiCorrection( diagnostic: AdapterSqlLintDiagnostic, ): string { diff --git a/ui/studio/views/sql/sql-lint-source.test.ts b/ui/studio/views/sql/sql-lint-source.test.ts index cd094f82..7afccaa5 100644 --- a/ui/studio/views/sql/sql-lint-source.test.ts +++ b/ui/studio/views/sql/sql-lint-source.test.ts @@ -76,6 +76,36 @@ describe("sql-lint-source", () => { ]); }); + it("passes selected schema context to lint requests", async () => { + const lintSql = vi.fn().mockResolvedValue([ + null, + { + diagnostics: [], + schemaVersion: "schema-v1", + }, + ]); + const { source } = createSqlLintSource({ + lintSql, + schema: "test_app", + schemaVersion: "schema-v1", + }); + + await expect( + source(createView("select * from order_items") as never), + ).resolves.toEqual([]); + + expect(lintSql).toHaveBeenCalledWith( + { + schema: "test_app", + schemaVersion: "schema-v1", + sql: "select * from order_items", + }, + expect.objectContaining({ + abortSignal: expect.any(AbortSignal) as AbortSignal, + }), + ); + }); + it("keeps only one active lint request and drops stale responses", async () => { const first = createDeferred<[null, { diagnostics: [] }]>(); const second = createDeferred<[null, { diagnostics: [] }]>(); diff --git a/ui/studio/views/sql/sql-lint-source.ts b/ui/studio/views/sql/sql-lint-source.ts index 8a74bbf7..5cc40faf 100644 --- a/ui/studio/views/sql/sql-lint-source.ts +++ b/ui/studio/views/sql/sql-lint-source.ts @@ -20,12 +20,13 @@ interface LintState { export function createSqlLintSource(args: { lintSql: SqlLintRunner; + schema?: string; schemaVersion?: string; }): { dispose: () => void; source: LintSource; } { - const { lintSql, schemaVersion } = args; + const { lintSql, schema, schemaVersion } = args; const state: LintState = { abortController: null, requestId: 0, @@ -48,6 +49,7 @@ export function createSqlLintSource(args: { const [error, result] = await lintSql( { + schema, schemaVersion, sql, }, From 65839cdd07504d3ec1747c44342e6b34cde0c75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Thu, 4 Jun 2026 19:17:13 +0700 Subject: [PATCH 2/3] Add changeset for schema SQL fix --- .changeset/silver-schema-sql.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/silver-schema-sql.md diff --git a/.changeset/silver-schema-sql.md b/.changeset/silver-schema-sql.md new file mode 100644 index 00000000..69a5b45f --- /dev/null +++ b/.changeset/silver-schema-sql.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": minor +--- + +Fix schema-aware SQL execution, linting, and Studio navigation so SQL queries and diagnostics resolve unqualified identifiers against the selected schema instead of always relying on the adapter default schema. From cc623bfbda93054f0475df81713828a8307d8fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Thu, 4 Jun 2026 20:03:49 +0700 Subject: [PATCH 3/3] Test SQL schema fallback handling --- ui/studio/views/sql/SqlView.test.tsx | 56 +++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/ui/studio/views/sql/SqlView.test.tsx b/ui/studio/views/sql/SqlView.test.tsx index 0be8ad68..5355c841 100644 --- a/ui/studio/views/sql/SqlView.test.tsx +++ b/ui/studio/views/sql/SqlView.test.tsx @@ -12,7 +12,8 @@ import { SqlView } from "./SqlView"; type StudioMock = ReturnType; const useStudioMock = vi.fn<() => StudioMock>(); -const useNavigationMock = vi.fn<() => { schemaParam: string }>(); +const useNavigationMock = + vi.fn<() => { schemaParam?: string | null | undefined }>(); const setPinnedColumnIdsMock = vi.fn(); let mockEditorCursorHead = 0; let mockEditorDocLength = 0; @@ -164,6 +165,7 @@ vi.mock("@uiw/react-codemirror", () => ({ function createAdapterMock(args?: { capabilities?: Adapter["capabilities"]; + defaultSchema?: string; raw?: Adapter["raw"]; sqlLint?: Adapter["sqlLint"]; }): { @@ -188,7 +190,7 @@ function createAdapterMock(args?: { return { adapter: { ...(args?.capabilities ? { capabilities: args.capabilities } : {}), - defaultSchema: "public", + defaultSchema: args?.defaultSchema ?? "public", delete: vi.fn(), insert: vi.fn(), introspect: vi.fn(), @@ -510,6 +512,56 @@ describe("SqlView", () => { harness.cleanup(); }); + it.each([ + ["null", null], + ["undefined", undefined], + ] as const)( + "falls back to the adapter default schema when the navigation schema is %s", + async (_label, schemaParam) => { + useNavigationMock.mockReturnValue({ + schemaParam, + }); + const { adapter, rawSpy } = createAdapterMock({ + defaultSchema: "tenant_default", + }); + const studio = createStudioMock(adapter); + useStudioMock.mockReturnValue(studio); + + const harness = renderSqlView(); + const runButton = [...harness.container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Run SQL"), + ); + + if (!runButton) { + throw new Error("SQL view run control not rendered"); + } + + act(() => { + mockCodeMirrorOnChange?.("select * from organizations"); + }); + + act(() => { + runButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => { + return ( + harness.container.textContent?.includes("1 row(s) returned") ?? false + ); + }); + + expect(rawSpy).toHaveBeenCalledWith( + { + schema: "tenant_default", + sql: "select * from organizations", + }, + expect.any(Object), + ); + + harness.cleanup(); + }, + ); + it("generates SQL, focuses the editor, and waits for a manual run", async () => { const { adapter, rawSpy } = createAdapterMock(); const studio = createStudioMock(adapter);