Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silver-schema-sql.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions Architecture/navigation-url-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions Architecture/sql-editor-intelligence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions Architecture/sql-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ type StudioBFFRequest =
| {
procedure: "query";
query: Query;
schema?: string;
customPayload?: Record<string, unknown>;
}
| {
Expand All @@ -378,6 +379,7 @@ type StudioBFFRequest =
| {
procedure: "sql-lint";
sql: string;
schema?: string;
schemaVersion?: string;
customPayload?: Record<string, unknown>;
}
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -469,7 +471,9 @@ export async function handleStudioBff(request: Request): Promise<Response> {
}

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]);
}

Expand Down Expand Up @@ -513,6 +517,7 @@ export async function handleStudioBff(request: Request): Promise<Response> {
}

const [error, result] = await executor.lintSql({
schema: payload.schema,
schemaVersion: payload.schemaVersion,
sql: payload.sql,
});
Expand Down
8 changes: 8 additions & 0 deletions data/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,10 @@ export interface AdapterQueryResult {
}

export interface AdapterRawDetails {
/**
* Schema to use as the default namespace for unqualified identifiers.
*/
schema?: string;
sql: string;
}

Expand All @@ -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;
}
Expand Down
23 changes: 23 additions & 0 deletions data/bff/bff-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -564,6 +585,7 @@ describe("bff/bff-client", () => {

describe("lintSql", () => {
const details = {
schema: "test_app",
schemaVersion: "schema-abc123",
sql: "select * from users",
};
Expand Down Expand Up @@ -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",
});
Expand Down
5 changes: 5 additions & 0 deletions data/bff/bff-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ export interface StudioBFFQueryRequest {
customPayload?: Record<string, unknown>;
procedure: "query";
query: Query<unknown>;
schema?: string;
}

export interface StudioBFFSequenceRequest {
Expand All @@ -296,6 +297,7 @@ export interface StudioBFFTransactionRequest {
}

export interface StudioBFFSqlLintDetails {
schema?: string;
schemaVersion?: string;
sql: string;
}
Expand All @@ -308,6 +310,7 @@ export interface StudioBFFSqlLintResult {
export interface StudioBFFSqlLintRequest {
customPayload?: Record<string, unknown>;
procedure: "sql-lint";
schema?: string;
schemaVersion?: string;
sql: string;
}
Expand Down Expand Up @@ -335,6 +338,7 @@ export function createStudioBFFClient(
customPayload,
procedure: "query",
query,
schema: options?.schema,
} satisfies StudioBFFQueryRequest),
headers: {
Accept: "application/json",
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions data/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
49 changes: 40 additions & 9 deletions data/pglite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<never>((_, reject) => (abort = reject));
Expand All @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 25 additions & 1 deletion data/postgres-core/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
10 changes: 8 additions & 2 deletions data/postgres-core/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,10 @@ async function lintWithExplainFallback(
const explainQuery = asQuery<Record<string, unknown>>(
`EXPLAIN ${statement.statement}`,
);
const [error] = await executor.execute(explainQuery, options);
const [error] = await executor.execute(explainQuery, {
...options,
schema: details.schema,
});

if (!error) {
continue;
Expand Down Expand Up @@ -434,7 +437,10 @@ async function executeRawQuery(
): Promise<Either<AdapterError, AdapterRawResult>> {
try {
const query = asQuery<Record<string, unknown>>(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({
Expand Down
Loading