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
6 changes: 4 additions & 2 deletions docs/API_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,8 @@ carries a plain statement. Four things differ from the other SQL providers:
catalog in full: `SELECT * FROM other_catalog.some_schema.t` runs unchanged. A connection with no
catalog runs fully qualified statements fine, but `GET /api/db/schema` refuses with the reason.
- **There is no `connectionString`.** `jdbc:trino://host:port/catalog/schema` exists, but the shared
parser does not accept it, so a connection is `host` + `port` (+ optional `database`, `username`).
parser does not accept it, so a connection is `host` + `port` (+ optional `database` catalog,
`schema`, and `username`).
A **`password` requires `ssl: true`**: the coordinator answers `401 Password not allowed for
insecure authentication` over plain HTTP even with authentication switched off, so a password on
an `http://` connection is refused by the provider rather than sent and rejected.
Expand Down Expand Up @@ -1267,7 +1268,7 @@ Body `{ "connections": [...] }`; returns per-connection health `{ "results": [{

The object is one shape on the wire. Fields the server reads from a request body — and that
change how a connection is opened — are the coordinates and credentials (`id`, `name`, `type`,
`host`, `port`, `user`, `password`, `database`, `connectionString`), plus `ssl`,
`host`, `port`, `user`, `password`, `database`, `schema`, `connectionString`), plus `ssl`,
`sshTunnel`, `serviceName` (Oracle), `instanceName` (MSSQL), `localDataCenter` (Cassandra),
`authSource` (MongoDB), `agentUser`, and `agentPassword`. `color`, `environment`, `group`,
`managed`, `seedId`, and `createdAt` are client-side bookkeeping that travel in the same object.
Expand All @@ -1282,6 +1283,7 @@ interface DatabaseConnection {
user?: string; // Username
password?: string; // Password
database?: string; // Database name (Couchbase: the bucket; Druid: unused, it has one catalog; Trino: the CATALOG; Cassandra: the KEYSPACE)
schema?: string; // Trino: session schema for unqualified table names
connectionString?: string; // Full connection string (alternative; Druid has no URI form, host + port only; Cassandra has none either, no URI carries localDataCenter)
createdAt: Date; // Creation timestamp
color?: string; // UI accent for this connection
Expand Down
3 changes: 3 additions & 0 deletions docs/SEED_CONNECTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ connections:
port: 8080 # The client protocol and the web UI share this port
database: hive # The CATALOG, not a database. Pins what the tree shows;
# a fully qualified name still reaches any other catalog.
schema: default # The session schema for unqualified table names. Without it,
# qualify names as schema.table in every statement.
user: "${TRINO_USER}"
roles: ["*"]
environment: production
Expand Down Expand Up @@ -149,6 +151,7 @@ connections:
| `connections[].host` | No | — | Hostname or IP |
| `connections[].port` | No | — | Port number (1-65535) |
| `connections[].database` | No | — | Database name (Couchbase: the bucket. Druid has one catalog and ignores it. Trino: the **catalog**) |
| `connections[].schema` | No | — | Trino session schema, used to resolve unqualified table names inside the configured catalog |
| `connections[].user` | No | — | Username |
| `connections[].password` | No | — | Password (use `${ENV_VAR}` syntax) |
| `connections[].connectionString` | No | — | Full connection string (use `${ENV_VAR}`). Druid and Trino have no URI form this build parses — those connections need `host` and are addressed by host and port only |
Expand Down
18 changes: 15 additions & 3 deletions docs/providers/trino.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,8 @@ Rather than let a user set a session property and watch the next query ignore it
attach a `QueryWarning`:

> *`"SET SESSION"` succeeded, but each statement is sent on its own connection, so it will not affect
> the next one. Qualify names in full instead.*
> the next one. Set Catalog Name and Schema Name on the Trino connection for a persistent namespace,
> or qualify names in full.*

The engine's own remarks travel the same way — measured, a redundant `ORDER BY` in a subquery answers
with rows plus `REDUNDANT_ORDER_BY`. They are de-duplicated, because the same remark is repeated on
Expand Down Expand Up @@ -475,12 +476,23 @@ quote character in its grammar at all.
| `host` | **Yes** | The coordinator. The only validated requirement |
| `port` | No | Defaults to `8080`, for both `http://` and `https://` |
| `database` | No | **The catalog** to pin ([§3.2](#32-the-connections-database-field-pins-one-catalog)). Without it the editor works and the tree does not |
| `schema` | No | Session schema for unqualified table names, sent as `X-Trino-Schema` |
| `username` | No | Sent as `X-Trino-User`. Defaults to `libredb`; never omitted ([§3.6](#36-a-password-is-a-tls-only-credential)) |
| `password` | No | `Authorization: Basic`, **and only over TLS** ([§4.3](#43-tls-and-the-password-rule)) |
| `ssl` | No | Selects `https://` |

There is no field for a session schema, which is why every generated name is qualified
`schema.table`: Trino resolves a bare name only when the session has a schema.
The connection form exposes **Catalog Name** and **Schema Name**. Set both to run
unqualified statements in the editor and Create Table: for example, Catalog `memory`
and Schema `default` let `SELECT * FROM widgets` and `CREATE TABLE t (id INTEGER NOT NULL)`
resolve inside `memory.default`. `SHOW SCHEMAS` lists the catalog's available schemas.
No schema is guessed: an omitted or empty value sends no schema header, so existing
connections keep their behavior until edited. Without a session schema, qualify table names.

Per-statement `TrinoQueryOptions.schema` overrides the connection default; an explicit empty
string omits the header for that statement. Headers still use the dialect's prefix. The schema
tree continues to list the whole catalog and qualify names as `schema.table`, so selecting a
table outside the session schema still targets that table. Fully qualified queries can still
reach other catalogs. The session schema is also supported in seeded connections.

### 4.2 There is no connection string — yet

Expand Down
17 changes: 17 additions & 0 deletions e2e/connection-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ test.describe("Connection Management", () => {
await expect(page.locator('input[value="localhost"]').first()).toBeVisible();
});

test("Trino sends the catalog and session schema entered in the form", async ({ page }) => {
await page.route("**/api/db/test-connection", (route) => route.fulfill({ json: { success: true, latency: 1 } }));
const sidebarButtons = page.locator("text=LibreDB Studio").locator("..").locator("..").locator("button");
await sidebarButtons.last().click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Trino", exact: true }).click();
await dialog.getByLabel("Catalog Name").fill("memory");
await dialog.getByLabel("Schema Name").fill("default");
const request = page.waitForRequest("**/api/db/test-connection");
await dialog.getByRole("button", { name: "Test Connection", exact: true }).click();
expect((await request).postDataJSON()).toMatchObject({
type: "trino",
database: "memory",
schema: "default",
});
});

test("connection modal can be closed", async ({ page }) => {
const sidebarButtons = page.locator("text=LibreDB Studio").locator("..").locator("..").locator("button");
await sidebarButtons.last().click();
Expand Down
24 changes: 24 additions & 0 deletions src/components/ConnectionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export function ConnectionModal({
setPassword,
database,
setDatabase,
schema,
setSchema,
connectionString,
setConnectionString,
mongoConnectionMode,
Expand Down Expand Up @@ -553,6 +555,28 @@ export function ConnectionModal({
</div>
)}

{takesConnectionField(type, "schema") && (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-1">
<Database strokeWidth={1.5} className="w-3 h-3 text-fg-muted" />
<Label htmlFor="schema" className="text-xs font-mediumr text-fg-muted">
Schema Name
</Label>
</div>
<Input
id="schema"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="default"
className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono"
/>
<p className="text-xs text-fg-muted">
Used for unqualified table names in queries and Create Table. Leave empty to qualify names
yourself. Run SHOW SCHEMAS to list the catalog's schemas.
</p>
</div>
)}

{/*
In the open rather than behind the Advanced accordion for the
reason Cassandra's field below is: the deployment that needs it is
Expand Down
8 changes: 8 additions & 0 deletions src/hooks/use-connection-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const FIELD_OWNERSHIP: Record<keyof DatabaseConnection, FieldOwnership> = {
user: "edited",
password: "edited",
database: "edited",
schema: "edited",
connectionString: "edited",
createdAt: "edited",
color: "edited",
Expand Down Expand Up @@ -133,6 +134,7 @@ export function useConnectionForm({ isOpen, onConnect, editConnection, onTestCon
const [user, setUser] = useState("");
const [password, setPassword] = useState("");
const [database, setDatabase] = useState("");
const [schema, setSchema] = useState("");
const [isTesting, setIsTesting] = useState(false);
const [connectionString, setConnectionString] = useState("");
const [mongoConnectionMode, setMongoConnectionMode] = useState<"host" | "connectionString">("host");
Expand Down Expand Up @@ -198,6 +200,7 @@ export function useConnectionForm({ isOpen, onConnect, editConnection, onTestCon
setUser(editConnection.user || "");
setPassword(editConnection.password || "");
setDatabase(editConnection.database || "");
setSchema(editConnection.schema || "");
setConnectionString(editConnection.connectionString || "");
setEnvironment(editConnection.environment || "local");
if (editConnection.connectionString) {
Expand Down Expand Up @@ -273,6 +276,7 @@ export function useConnectionForm({ isOpen, onConnect, editConnection, onTestCon
setUser("");
setPassword("");
setDatabase("");
setSchema("");
setConnectionString("");
setMongoConnectionMode("host");
setType("postgres");
Expand Down Expand Up @@ -341,6 +345,7 @@ export function useConnectionForm({ isOpen, onConnect, editConnection, onTestCon
...(addressedFields.has("user") ? { user } : {}),
...(addressedFields.has("password") ? { password } : {}),
...(addressedFields.has("database") ? { database } : {}),
...(addressedFields.has("schema") && schema ? { schema } : {}),
createdAt: editConnection?.createdAt || new Date(),
environment,
color: ENVIRONMENT_COLORS[environment],
Expand Down Expand Up @@ -381,6 +386,7 @@ export function useConnectionForm({ isOpen, onConnect, editConnection, onTestCon
user,
password,
database,
schema,
environment,
mongoConnectionMode,
connectionString,
Expand Down Expand Up @@ -623,7 +629,9 @@ export function useConnectionForm({ isOpen, onConnect, editConnection, onTestCon
password,
setPassword,
database,
schema,
setDatabase,
setSchema,
connectionString,
setConnectionString,
mongoConnectionMode,
Expand Down
1 change: 1 addition & 0 deletions src/hooks/use-connection-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const CONNECTION_RELEVANCE: Record<keyof DatabaseConnection, FieldRelevance> = {
user: "resolution",
password: "resolution",
database: "resolution",
schema: "resolution",
connectionString: "resolution",
serviceName: "resolution",
instanceName: "resolution",
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent/context-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,7 @@ export function connectionIdentity(connection: DatabaseConnection): string {
connection.host ?? "",
connection.port ?? "",
connection.database ?? "",
connection.schema ?? "",
connection.connectionString ?? "",
connection.serviceName ?? "",
connection.instanceName ?? "",
Expand Down
3 changes: 2 additions & 1 deletion src/lib/db-ui-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface DatabaseUIConfig {
| "user"
| "password"
| "database"
| "schema"
| "connectionString"
| "serviceName"
| "instanceName"
Expand Down Expand Up @@ -255,7 +256,7 @@ export const DB_UI_CONFIG: Record<DatabaseType, DatabaseUIConfig> = {
// `SHOW CATALOGS` answers jmx, memory, system, tpcds, tpch) and a connection pins
// one, the way a PostgreSQL connection pins a database. The form labels it
// "Catalog" rather than "Database" - see ConnectionModal.tsx.
connectionFields: ["host", "port", "user", "password", "database"],
connectionFields: ["host", "port", "user", "password", "database", "schema"],
},
cassandra: {
icon: CassandraIcon,
Expand Down
7 changes: 4 additions & 3 deletions src/lib/db/providers/sql/trino/http-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@ export class TrinoHttpTransport implements TrinoTransport {
private readonly origin: string;
private readonly user: string;
private readonly catalog: string | undefined;
private readonly schema: string | undefined;
private readonly authorization: string | undefined;

constructor(dialect: TrinoDialect, config: DatabaseConnection) {
Expand All @@ -706,6 +707,7 @@ export class TrinoHttpTransport implements TrinoTransport {
// boundary: a fully qualified statement still reaches any catalog the session
// can see.
this.catalog = config.database;
this.schema = config.schema;

if (config.password === undefined || config.password === "") {
this.authorization = undefined;
Expand Down Expand Up @@ -880,14 +882,13 @@ export class TrinoHttpTransport implements TrinoTransport {
*/
private submitHeaders(options: TrinoQueryOptions): Record<string, string> {
const catalog = options.catalog ?? this.catalog;
const schema = options.schema ?? this.schema;
return {
...this.sessionHeaders(),
"content-type": SQL_CONTENT_TYPE,
[this.header(HEADER_SUFFIXES.TIME_ZONE)]: CLIENT_TIME_ZONE,
...(catalog === undefined || catalog === "" ? {} : { [this.header(HEADER_SUFFIXES.CATALOG)]: catalog }),
...(options.schema === undefined || options.schema === ""
? {}
: { [this.header(HEADER_SUFFIXES.SCHEMA)]: options.schema }),
...(schema === undefined || schema === "" ? {} : { [this.header(HEADER_SUFFIXES.SCHEMA)]: schema }),
};
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/db/providers/sql/trino/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ function engineWarnings(result: TrinoQueryResult): QueryWarning[] {

if (result.operation !== null && SESSION_SCOPED_OPERATIONS.has(result.operation)) {
warnings.push({
message: `"${result.operation}" succeeded, but each statement is sent on its own connection, so it will not affect the next one. Qualify names in full instead.`,
message: `"${result.operation}" succeeded, but each statement is sent on its own connection, so it will not affect the next one. Set Catalog Name and Schema Name on the Trino connection for a persistent namespace, or qualify names in full.`,
});
}

Expand Down Expand Up @@ -458,7 +458,7 @@ export class TrinoProvider extends SQLBaseProvider {
const catalog = this.config.database;
if (catalog === undefined || catalog === "") {
throw new DatabaseConfigError(
`This connection pins no ${this.dialect.displayName} catalog, so there is no schema to list. Set the catalog on the connection, or qualify every name in full.`,
`This connection pins no ${this.dialect.displayName} catalog, so there is no schema to list. Set the catalog on the connection to list its tables. Set a session schema as well to use unqualified table names in queries and Create Table.`,
this.type,
);
}
Expand Down
6 changes: 3 additions & 3 deletions src/lib/db/providers/sql/trino/introspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,9 @@ function readTableAddresses(rows: TrinoRow[]): TableAddress[] {
* the cluster has configured being reachable.
*
* A table's display name is therefore `schema.table`, always qualified: Trino
* resolves an unqualified name only when the SESSION has a schema, and this
* transport pins a catalog and no schema (there is no connection field for one),
* so a bare name in a generated statement would not resolve at all.
* lists tables across every schema in the catalog, regardless of the optional
* session schema pinned on the connection. Qualifying names keeps tree-generated
* statements targeting the selected table even when it is outside that schema.
*/
export async function getSchema(runner: TrinoQueryRunner, catalog: string): Promise<TableSchema[]> {
const [tableRows, columnRows] = await Promise.all([
Expand Down
1 change: 1 addition & 0 deletions src/lib/seed/connection-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function filterByRoles(connections: SeedConnection[], userRoles: string[]
// MongoDB's auth database. Dropping it here would list a seeded connection that
// authenticates against the wrong database and reports a credentials error.
authSource: conn.authSource,
schema: conn.schema,
createdAt: new Date(),
managed: conn.managed ?? true,
roles: conn.roles,
Expand Down
1 change: 1 addition & 0 deletions src/lib/seed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const SeedConnectionSchema = z.object({
// deployment). Optional because the driver falls back to the database being opened,
// which is right only when the two are the same.
authSource: z.string().optional(),
schema: z.string().optional(),
});

export const SeedConfigSchema = z
Expand Down
1 change: 1 addition & 0 deletions src/lib/storage/connection-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const CONNECTION_FIELDS: Record<keyof DatabaseConnection, FieldClass> = {
// A DATABASE NAME (`admin`), not a credential. The password that authenticates
// against it is the secret, and it is classified above.
authSource: "public",
schema: "public",
managed: "public",
seedId: "public",
agentUser: "public",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export interface DatabaseConnection {
user?: string;
password?: string;
database?: string;
/** Trino: the session schema used to resolve unqualified table names. */
schema?: string;
connectionString?: string;
createdAt: Date;
color?: string;
Expand Down
14 changes: 14 additions & 0 deletions tests/components/ConnectionModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ function getDefaultForm() {
setInstanceName: mockSetInstanceName,
localDataCenter: "",
setLocalDataCenter: mockSetLocalDataCenter,
schema: "",
setSchema: mock(() => {}),
authSource: "",
setAuthSource: mockSetAuthSource,
showSSH: false,
Expand Down Expand Up @@ -234,6 +236,7 @@ mock.module("@/hooks/use-connection-form", () => ({
// passes or fails against THIS list, not against src/lib/db-ui-config.ts. The real table is
// the authority and tests/unit/lib/db-ui-config.test.ts derives it from the providers.
const MOCK_CONNECTION_FIELDS: Record<string, string[]> = {
trino: ["host", "port", "user", "password", "database", "schema"],
sqlite: ["database"],
libredb: ["database"],
duckdb: ["database"],
Expand Down Expand Up @@ -840,6 +843,16 @@ describe("ConnectionModal", () => {
expect(queryByText(/The Trino catalog to open/)).not.toBeNull();
});

test("Trino exposes an editable session schema beside the catalog", () => {
const setSchema = mock(() => {});
mockFormOverrides = { type: "trino", schema: "tiny", setSchema };
const { getByLabelText } = render(React.createElement(ConnectionModal, createDefaultProps()));
const input = getByLabelText("Schema Name") as HTMLInputElement;
expect(input.value).toBe("tiny");
fireEvent.change(input, { target: { value: "default" } });
expect(setSchema).toHaveBeenCalledWith("default");
});

test("Trino warns that a password needs TLS, before the connection can 401 on it", () => {
// Measured on 476 with authentication DISABLED: `Authorization: Basic` over plain
// HTTP is answered 401, "Password not allowed for insecure authentication". So
Expand All @@ -857,6 +870,7 @@ describe("ConnectionModal", () => {
const { queryByText } = render(React.createElement(ConnectionModal, props));

expect(queryByText("Catalog Name")).toBeNull();
expect(queryByText("Schema Name")).toBeNull();
expect(queryByText(/refuses a password over plain HTTP/)).toBeNull();
});

Expand Down
Loading
Loading