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. 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..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(); +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(), @@ -330,16 +332,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 +473,106 @@ 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.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); - 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 +643,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 +745,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 +946,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 +971,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 +1054,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 +1069,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 +1210,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 +1289,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 +1374,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 +1407,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 +1427,7 @@ describe("SqlView", () => { xKey: "label", }, }), + ), ); studio.llm = llmMock; useStudioMock.mockReturnValue(studio); @@ -1446,15 +1554,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 +1626,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 +1683,7 @@ describe("SqlView", () => { }); expect(rawSpy).toHaveBeenCalledWith( - { sql: "select * from" }, + { schema: "public", sql: "select * from" }, expect.any(Object), ); const [firstRawCallArgs] = rawSpy.mock.calls; @@ -1701,7 +1827,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, },