@rebasepro/studio 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/{ApiExplorer-D-jLWAaY.js → ApiExplorer-CCtczj3F.js} +16 -32
  2. package/dist/ApiExplorer-CCtczj3F.js.map +1 -0
  3. package/dist/ApiKeysView-B66TdzhH.js +1738 -0
  4. package/dist/ApiKeysView-B66TdzhH.js.map +1 -0
  5. package/dist/BackupsView-DmSMzAbT.js +368 -0
  6. package/dist/BackupsView-DmSMzAbT.js.map +1 -0
  7. package/dist/{BranchesView-D6Sz1a5f.js → BranchesView-DP1a87E-.js} +12 -8
  8. package/dist/BranchesView-DP1a87E-.js.map +1 -0
  9. package/dist/{CronJobsView-JwRjQCde.js → CronJobsView-TDErHTuK.js} +143 -68
  10. package/dist/CronJobsView-TDErHTuK.js.map +1 -0
  11. package/dist/{JSEditor-qiuwmcvk.js → JSEditor-B59pwg4c.js} +120 -74
  12. package/dist/JSEditor-B59pwg4c.js.map +1 -0
  13. package/dist/{LogsExplorer-Xq8K4oo6.js → LogsExplorer-BKM4yfP8.js} +48 -22
  14. package/dist/LogsExplorer-BKM4yfP8.js.map +1 -0
  15. package/dist/{MonacoEditor-D0_QljcX.js → MonacoEditor-CUxy5_k5.js} +11 -9
  16. package/dist/MonacoEditor-CUxy5_k5.js.map +1 -0
  17. package/dist/{RLSEditor-B-qrgdHy.js → RLSEditor-BlLLV6xs.js} +350 -98
  18. package/dist/RLSEditor-BlLLV6xs.js.map +1 -0
  19. package/dist/{SQLEditor-sdtoZv5F.js → SQLEditor-1An7w0j6.js} +496 -224
  20. package/dist/SQLEditor-1An7w0j6.js.map +1 -0
  21. package/dist/{SchemaVisualizer-DtDzIUNy.js → SchemaVisualizer-DCLVfA5t.js} +20 -24
  22. package/dist/SchemaVisualizer-DCLVfA5t.js.map +1 -0
  23. package/dist/{StorageView-C52dSZNf.js → StorageView-CbsHuCBf.js} +382 -228
  24. package/dist/StorageView-CbsHuCBf.js.map +1 -0
  25. package/dist/components/ApiExplorer/TryItPanel.d.ts +6 -1
  26. package/dist/components/ApiKeys/CreateApiKeyDialog.d.ts +21 -2
  27. package/dist/components/ApiKeys/scope-words.d.ts +37 -0
  28. package/dist/components/ApiKeys/scopes.d.ts +113 -0
  29. package/dist/components/AuthSimulationSelector.d.ts +5 -1
  30. package/dist/components/LogsExplorer/useLogTail.d.ts +9 -0
  31. package/dist/components/RLSEditor/policyListing.d.ts +44 -0
  32. package/dist/components/RLSEditor/policyRules.d.ts +49 -0
  33. package/dist/components/SQLEditor/MonacoEditor.d.ts +13 -1
  34. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +0 -1
  35. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +6 -0
  36. package/dist/components/load-failure.d.ts +1 -1
  37. package/dist/components/useRunAsUsers.d.ts +25 -0
  38. package/dist/index.es.js +11 -11
  39. package/dist/{load-failure-C6OMJSGC.js → load-failure-Bd8lFIO7.js} +2 -2
  40. package/dist/load-failure-Bd8lFIO7.js.map +1 -0
  41. package/dist/useRunAsUsers-CLaAtXt6.js +122 -0
  42. package/dist/useRunAsUsers-CLaAtXt6.js.map +1 -0
  43. package/dist/utils/sql_utils.d.ts +126 -36
  44. package/package.json +9 -9
  45. package/dist/ApiExplorer-D-jLWAaY.js.map +0 -1
  46. package/dist/ApiKeysView-OfJLH2x7.js +0 -1239
  47. package/dist/ApiKeysView-OfJLH2x7.js.map +0 -1
  48. package/dist/AuthSimulationSelector-BewmhMBw.js +0 -56
  49. package/dist/AuthSimulationSelector-BewmhMBw.js.map +0 -1
  50. package/dist/BackupsView-DYTAD6aW.js +0 -253
  51. package/dist/BackupsView-DYTAD6aW.js.map +0 -1
  52. package/dist/BranchesView-D6Sz1a5f.js.map +0 -1
  53. package/dist/CronJobsView-JwRjQCde.js.map +0 -1
  54. package/dist/JSEditor-qiuwmcvk.js.map +0 -1
  55. package/dist/LogsExplorer-Xq8K4oo6.js.map +0 -1
  56. package/dist/MonacoEditor-D0_QljcX.js.map +0 -1
  57. package/dist/RLSEditor-B-qrgdHy.js.map +0 -1
  58. package/dist/SQLEditor-sdtoZv5F.js.map +0 -1
  59. package/dist/SchemaVisualizer-DtDzIUNy.js.map +0 -1
  60. package/dist/StorageView-C52dSZNf.js.map +0 -1
  61. package/dist/components/ApiKeys/permissions.d.ts +0 -66
  62. package/dist/load-failure-C6OMJSGC.js.map +0 -1
@@ -1,11 +1,11 @@
1
- import { t as MonacoEditor } from "./MonacoEditor-D0_QljcX.js";
1
+ import { t as MonacoEditor } from "./MonacoEditor-CUxy5_k5.js";
2
2
  import { ConfirmationDialog, ErrorView, IconForView, useRebaseContext, useSnackbarController, useStudioCollectionRegistry, useStudioSidePanelController, useTranslation } from "@rebasepro/app";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { Button, Checkbox, CircularProgress, Collapse, CopyIcon, DatabaseIcon, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuIcon, MenuItem, MoreVerticalIcon, PencilIcon, PlayIcon, PlusIcon, RefreshCwIcon, ResizablePanels, Tab, Tabs, TerminalIcon, TextField, TextareaAutosize, Tooltip, Trash2Icon, Typography, VirtualTable, XIcon, cls, defaultBorderMixin, iconSize } from "@rebasepro/ui";
4
+ import { Alert, Button, Checkbox, CircularProgress, Collapse, CopyIcon, DatabaseIcon, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuIcon, MenuItem, MoreVerticalIcon, PencilIcon, PlayIcon, PlusIcon, RefreshCwIcon, ResizablePanels, Tab, Tabs, TerminalIcon, TextField, TextareaAutosize, Tooltip, Trash2Icon, Typography, VirtualTable, XIcon, cls, defaultBorderMixin, iconSize, isKeyHandled } from "@rebasepro/ui";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { isArrayValue, isRecordValue, readStoredJson, readStoredString, toSnakeCase, writeStoredJson, writeStoredString } from "@rebasepro/utils";
7
7
  import { createPortal } from "react-dom";
8
- import { parseFirst } from "pgsql-ast-parser";
8
+ import { astVisitor, parse } from "pgsql-ast-parser";
9
9
  //#region src/components/SQLEditor/SchemaBrowser.tsx
10
10
  var SchemaBrowser = ({ onTableClick, schemas, isSchemaLoading, schemaError, onRetrySchema }) => {
11
11
  const [expandedSchemas, setExpandedSchemas] = useState({ public: true });
@@ -393,152 +393,332 @@ var SQLEditorSidebar = ({ onSelectSnippet, onTableClick, snippets, history, onDe
393
393
  };
394
394
  //#endregion
395
395
  //#region src/utils/sql_utils.ts
396
- /**
397
- * Extract all tables referenced in a SQL query's FROM and JOIN clauses.
398
- * Returns an empty array for non-SELECT queries or parse failures.
399
- */
400
- function extractTablesFromQuery(sqlString) {
396
+ /** The statements in `sqlText`, or `null` when the parser cannot read it. */
397
+ function parseStatements(sqlText) {
401
398
  try {
402
- const ast = parseFirst(sqlString);
403
- if (ast.type !== "select") return [];
404
- const tables = [];
405
- const processFrom = (fromItems) => {
406
- for (const item of fromItems) {
407
- if (item.type === "table" && item.name) tables.push({
408
- name: item.name.name,
409
- alias: item.name.alias
410
- });
411
- if (item.type === "join") {
412
- if (item.left) processFrom([item.left]);
413
- if (item.right) processFrom([item.right]);
414
- }
415
- }
416
- };
417
- if (ast.from) processFrom(ast.from);
418
- return tables;
399
+ return parse(sqlText);
419
400
  } catch {
420
- return [];
401
+ return null;
421
402
  }
422
403
  }
423
404
  /**
424
- * Resolve which collections are referenced by a SQL query.
405
+ * The `EXPLAIN` for one statement, or `null` when `sqlText` is not exactly one.
425
406
  *
426
- * Parses the SQL, extracts table names, and matches each against
427
- * registered collections via `collection.table` (falling back to
428
- * snake_case of `collection.slug`).
407
+ * Never `ANALYZE`: that executes the statement to time it, so explaining a
408
+ * `DELETE` deleted the rows — without the confirmation "Run" asks for.
429
409
  *
430
- * For each matched collection, determines which result columns
431
- * belong to that table using the database schema information.
410
+ * And never more than one statement. Sent as one simple query, `EXPLAIN (…)
411
+ * SELECT 1; DELETE …` explains the SELECT and *runs* the DELETE. Text the
412
+ * parser cannot read is still explained, as long as no `;` could be hiding a
413
+ * second statement in it.
432
414
  */
433
- function resolveQueryCollections(sqlString, schemas, collections, resultColumns) {
434
- const tables = extractTablesFromQuery(sqlString);
435
- if (tables.length === 0) return [];
436
- const selectColumns = [];
437
- try {
438
- const ast = parseFirst(sqlString);
439
- if (ast.type === "select" && ast.columns) {
440
- for (const col of ast.columns) if (col.expr?.type === "ref") selectColumns.push({
441
- table: col.expr.table?.name,
442
- column: col.expr.name,
443
- alias: col.alias?.name
444
- });
415
+ function buildExplainSql(sqlText) {
416
+ const statement = sqlText.trim().replace(/;+$/, "").trim();
417
+ if (!statement) return null;
418
+ const statements = parseStatements(statement);
419
+ return (statements ? statements.length === 1 : !statement.includes(";")) ? `EXPLAIN (FORMAT JSON) ${statement}` : null;
420
+ }
421
+ /**
422
+ * Whether the console's automatic `LIMIT` may be appended to `sqlText`: one
423
+ * top-level SELECT that has no limit of its own.
424
+ *
425
+ * Decided on the parsed statement, not the text. A search for the word SELECT
426
+ * also found it inside `INSERT INTO … SELECT` and `CREATE TABLE … AS SELECT`,
427
+ * which then copied a thousand rows and reported success, and at the start of
428
+ * a script whose last statement the appended `LIMIT` turned into a syntax
429
+ * error. Text the parser cannot read is left as written.
430
+ */
431
+ function acceptsAutoLimit(sqlText) {
432
+ const statements = parseStatements(sqlText);
433
+ if (!statements || statements.length !== 1) return false;
434
+ const [statement] = statements;
435
+ return statement.type === "select" && !statement.limit;
436
+ }
437
+ /** A destructive command named anywhere in a text, comments included. */
438
+ var DESTRUCTIVE_COMMAND = /\b(DELETE|UPDATE|DROP|TRUNCATE)\b/i;
439
+ function isDestructiveStatement(statement) {
440
+ switch (statement.type) {
441
+ case "delete":
442
+ case "update": return !statement.where;
443
+ case "truncate table":
444
+ case "drop table":
445
+ case "drop sequence":
446
+ case "drop index":
447
+ case "drop type":
448
+ case "drop trigger":
449
+ case "drop function": return true;
450
+ case "alter table": return statement.changes.some((change) => change.type === "drop column" || change.type === "drop constraint");
451
+ case "with": return statement.bind.some((binding) => isDestructiveStatement(binding.statement)) || isDestructiveStatement(statement.in);
452
+ case "with recursive": return isDestructiveStatement(statement.in);
453
+ case "do": return DESTRUCTIVE_COMMAND.test(statement.code);
454
+ default: return false;
455
+ }
456
+ }
457
+ /**
458
+ * Whether "Run" asks before running `sqlText`: a statement in it deletes or
459
+ * updates without a WHERE of its own, drops or truncates.
460
+ *
461
+ * Decided per parsed statement, not on the text. A search of the text for the
462
+ * word WHERE found it in another statement or in a comment, so `DELETE FROM
463
+ * posts; SELECT * FROM posts WHERE id = 1` deleted every post without asking.
464
+ * Text the parser cannot read is confirmed whenever it names a destructive
465
+ * command.
466
+ */
467
+ function needsDestructiveConfirmation(sqlText) {
468
+ const statements = parseStatements(sqlText);
469
+ if (!statements) return DESTRUCTIVE_COMMAND.test(sqlText);
470
+ return statements.some(isDestructiveStatement);
471
+ }
472
+ /**
473
+ * A table as SQL: `"schema"."table"` when a schema is given, a bare `"table"`
474
+ * otherwise.
475
+ */
476
+ function quoteTableName(tableName, schemaName) {
477
+ return schemaName ? `${quoteIdentifier(schemaName)}.${quoteIdentifier(tableName)}` : quoteIdentifier(tableName);
478
+ }
479
+ /** An identifier as SQL, whatever it holds: double-quoted, with `"` doubled. */
480
+ function quoteIdentifier(identifier) {
481
+ return `"${identifier.replace(/"/g, "\"\"")}"`;
482
+ }
483
+ var refuse = (key, params = {}) => ({ refusal: {
484
+ key,
485
+ params
486
+ } });
487
+ /**
488
+ * Which stored cell an edit of the result column `resultColumn` writes, and
489
+ * how its row is found again — or why no edit is safe.
490
+ *
491
+ * Decided on what the database reported, not on the query's text. Each
492
+ * column of the result says which table column it was read from, or that it
493
+ * was computed; a cell is editable only when it was read from a column of a
494
+ * table, every column of that table's primary key was read too, and each of
495
+ * them is the only column of the result with its name — a row holds one value
496
+ * per name.
497
+ *
498
+ * The text was the only evidence before, and it was wrong twice over. For
499
+ * `SELECT p.id, a.name FROM posts p JOIN authors a …` it took the post's `id`
500
+ * for the author's key and updated whichever author shared the post's id.
501
+ * For `SELECT id, lower(email) AS name FROM users` it wrote the edited text
502
+ * to `users.name`.
503
+ *
504
+ * The database reports a table, not which mention of it: in a self-join both
505
+ * sides are `authors`, and a key from one with a value from the other finds
506
+ * the wrong row. So the query is still read, for one thing — the table must
507
+ * be read once, and nothing it is read through may be read twice.
508
+ */
509
+ function resolveCellEdit(sqlText, provenance, resultColumn) {
510
+ const sameName = provenance.columns.filter((column) => column.name === resultColumn);
511
+ if (sameName.length !== 1) return refuse("studio_sql_edit_duplicate_column", { column: resultColumn });
512
+ const source = sameName[0].source;
513
+ if (!source) return refuse("studio_sql_edit_computed_column", { column: resultColumn });
514
+ const tableLabel = `${source.schema}.${source.table}`;
515
+ const table = provenance.tables.find((t) => t.schema === source.schema && t.table === source.table);
516
+ if (!table || table.kind !== "table" && table.kind !== "partitioned table") return refuse("studio_sql_edit_not_a_table", {
517
+ column: resultColumn,
518
+ table: tableLabel
519
+ });
520
+ if (table.hasInheritors) return refuse("studio_sql_edit_inherited_table", { table: tableLabel });
521
+ if (table.primaryKey.length === 0) return refuse("studio_sql_edit_no_primary_key", { table: tableLabel });
522
+ const key = [];
523
+ for (const keyColumn of table.primaryKey) {
524
+ const holders = provenance.columns.filter((column) => column.source?.schema === source.schema && column.source.table === source.table && column.source.column === keyColumn);
525
+ const holder = holders.length === 1 && provenance.columns.filter((column) => column.name === holders[0].name).length === 1 ? holders[0] : void 0;
526
+ if (!holder) return refuse("studio_sql_edit_key_missing", {
527
+ table: tableLabel,
528
+ columns: table.primaryKey.join(", ")
529
+ });
530
+ key.push({
531
+ column: keyColumn,
532
+ resultColumn: holder.name
533
+ });
534
+ }
535
+ const readOnce = tableReadOnce(sqlText, source.table);
536
+ if (readOnce !== true) return readOnce;
537
+ return { target: {
538
+ schema: source.schema,
539
+ table: source.table,
540
+ column: source.column,
541
+ key
542
+ } };
543
+ }
544
+ /**
545
+ * Whether `sqlText` is one SELECT that reads `table` once — counting every
546
+ * mention of a table of that name, in any schema and at any depth, and every
547
+ * mention of a `WITH` query, since one read twice reads its tables twice.
548
+ */
549
+ function tableReadOnce(sqlText, table) {
550
+ const statements = parseStatements(sqlText);
551
+ if (!statements) return refuse("studio_sql_edit_unreadable_query");
552
+ if (statements.length !== 1) return refuse("studio_sql_edit_not_a_select");
553
+ const [statement] = statements;
554
+ if (!(statement.type === "select" || statement.type === "with" && statement.in.type === "select" && statement.bind.every((binding) => binding.statement.type === "select"))) return refuse("studio_sql_edit_not_a_select");
555
+ const mentions = /* @__PURE__ */ new Map();
556
+ const withNames = [];
557
+ astVisitor((visitor) => ({
558
+ fromTable: (from) => {
559
+ const name = from.name.name.toLowerCase();
560
+ mentions.set(name, (mentions.get(name) ?? 0) + 1);
561
+ visitor.super().fromTable(from);
562
+ },
563
+ with: (withStatement) => {
564
+ withNames.push(...withStatement.bind.map((binding) => binding.alias.name.toLowerCase()));
565
+ visitor.super().with(withStatement);
445
566
  }
446
- } catch {}
567
+ })).statement(statement);
568
+ return (mentions.get(table.toLowerCase()) ?? 0) !== 1 || withNames.some((name) => (mentions.get(name) ?? 0) > 1) ? refuse("studio_sql_edit_table_read_twice", { table }) : true;
569
+ }
570
+ /**
571
+ * The collections a result's rows can be opened as: those whose table one of
572
+ * the result's columns was read from, with the table's (single-column) primary
573
+ * key read too, under a name no other column of the result has.
574
+ *
575
+ * Matched on the provenance the database reported. A text match took the
576
+ * result's `id` for every table of a join: `SELECT p.id, a.name FROM posts p
577
+ * JOIN authors a …` offered to open the author whose id was the post's.
578
+ */
579
+ function resolveQueryCollections(provenance, collections) {
447
580
  const results = [];
448
- for (const table of tables) {
449
- const matched = collections.find((c) => {
450
- return (("table" in c ? c.table : void 0) || toSnakeCase(c.slug)) === table.name;
581
+ for (const table of provenance.tables) {
582
+ const collection = collections.find((c) => {
583
+ const tableName = ("table" in c ? c.table : void 0) || toSnakeCase(c.slug);
584
+ const schemaName = ("schema" in c ? c.schema : void 0) || "public";
585
+ return tableName === table.table && schemaName === table.schema;
451
586
  });
452
- if (!matched) continue;
453
- const tableColumns = [];
454
- for (const schemaEntries of Object.values(schemas)) {
455
- const tableInfo = schemaEntries.find((t) => t.tableName === table.name);
456
- if (tableInfo) {
457
- tableColumns.push(...tableInfo.columns.map((c) => c.name));
458
- break;
459
- }
460
- }
461
- let pkColumn;
462
- const tableRef = table.alias || table.name;
463
- const idSelectCol = selectColumns.find((sc) => sc.column === "id" && (!sc.table || sc.table === tableRef || sc.table === table.name));
464
- if (idSelectCol) pkColumn = idSelectCol.alias || idSelectCol.column;
465
- if (!pkColumn && resultColumns) {
466
- if (resultColumns.includes("id")) pkColumn = "id";
467
- }
468
- if (!pkColumn && tableColumns.includes("id")) pkColumn = "id";
587
+ if (!collection || table.primaryKey.length !== 1) continue;
588
+ const [keyColumn] = table.primaryKey;
589
+ const holders = provenance.columns.filter((column) => column.source?.schema === table.schema && column.source.table === table.table && column.source.column === keyColumn);
590
+ if (holders.length !== 1) continue;
591
+ const pkColumn = holders[0].name;
592
+ if (provenance.columns.filter((column) => column.name === pkColumn).length !== 1) continue;
469
593
  results.push({
470
- tableName: table.name,
471
- tableAlias: table.alias,
472
- collection: matched,
473
- columns: tableColumns,
594
+ tableName: table.table,
595
+ schemaName: table.schema,
596
+ collection,
474
597
  pkColumn
475
598
  });
476
599
  }
477
600
  return results;
478
601
  }
479
- function determineTableAndPK(sqlString, columnKey, schemas) {
480
- try {
481
- const tables = extractTablesFromQuery(sqlString);
482
- const ast = parseFirst(sqlString);
483
- if (ast.type !== "select") return { error: "Inline editing is only supported for SELECT queries." };
484
- if (tables.length === 0) return { error: "Could not find any tables in the query." };
485
- const selectColumns = [];
486
- if (ast.columns) {
487
- for (const col of ast.columns) if (col.expr?.type === "ref") selectColumns.push({
488
- table: col.expr.table?.name,
489
- column: col.expr.name,
490
- alias: col.alias?.name
491
- });
602
+ /** A character an unquoted identifier or keyword can hold, `$` included. */
603
+ var IDENTIFIER_CHARACTER = /[A-Za-z0-9_$]/;
604
+ /**
605
+ * Cut `sqlText` into code and the things in it whose text is data.
606
+ *
607
+ * A lexer, not a parser: it reads text the parser refuses as well, which is
608
+ * most of what a person types while writing a query.
609
+ */
610
+ function splitSql(sqlText) {
611
+ const segments = [];
612
+ let code = "";
613
+ const push = (text, kind) => {
614
+ if (code) segments.push({
615
+ text: code,
616
+ kind: "code"
617
+ });
618
+ code = "";
619
+ segments.push({
620
+ text,
621
+ kind
622
+ });
623
+ };
624
+ let i = 0;
625
+ while (i < sqlText.length) {
626
+ const ch = sqlText[i];
627
+ const next = sqlText[i + 1];
628
+ if (ch === "-" && next === "-") {
629
+ const newline = sqlText.indexOf("\n", i);
630
+ const end = newline === -1 ? sqlText.length : newline + 1;
631
+ push(sqlText.slice(i, end), "line comment");
632
+ i = end;
633
+ continue;
492
634
  }
493
- const resolvedColumn = selectColumns.find((sc) => sc.alias === columnKey || !sc.alias && sc.column === columnKey);
494
- const actualDbColumnName = resolvedColumn?.column ?? columnKey;
495
- const columnTableRef = resolvedColumn?.table;
496
- let resolvedTableName = null;
497
- if (tables.length === 1) resolvedTableName = tables[0].name;
498
- else {
499
- if (columnTableRef) {
500
- const matchedTable = tables.find((t) => t.alias === columnTableRef || t.name === columnTableRef);
501
- if (matchedTable) resolvedTableName = matchedTable.name;
502
- }
503
- if (!resolvedTableName) {
504
- const matchedTables = tables.filter((t) => {
505
- for (const schema of Object.values(schemas)) {
506
- const tableInfo = schema.find((ti) => ti.tableName === t.name);
507
- if (tableInfo && tableInfo.columns.some((c) => c.name === actualDbColumnName)) return true;
508
- }
509
- return false;
510
- });
511
- if (matchedTables.length === 1) resolvedTableName = matchedTables[0].name;
512
- else if (matchedTables.length > 1) return { error: `Ambiguous column "${columnKey}": Found in multiple queried tables.` };
513
- else return { error: `Could not find column "${columnKey}" in the queried tables.` };
514
- }
635
+ if (ch === "/" && next === "*") {
636
+ let depth = 1;
637
+ let j = i + 2;
638
+ while (j < sqlText.length && depth > 0) if (sqlText[j] === "/" && sqlText[j + 1] === "*") {
639
+ depth++;
640
+ j += 2;
641
+ } else if (sqlText[j] === "*" && sqlText[j + 1] === "/") {
642
+ depth--;
643
+ j += 2;
644
+ } else j++;
645
+ push(sqlText.slice(i, j), "block comment");
646
+ i = j;
647
+ continue;
515
648
  }
516
- if (!resolvedTableName) return { error: "Could not resolve the target table." };
517
- let pkDbColumns = [];
518
- for (const schema of Object.values(schemas)) {
519
- const tableInfo = schema.find((t) => t.tableName === resolvedTableName);
520
- if (tableInfo) {
521
- pkDbColumns = tableInfo.columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
649
+ if (ch === "'" || ch === "\"") {
650
+ const backslashEscapes = ch === "'" && /(^|[^A-Za-z0-9_$])[eE]$/.test(code);
651
+ let j = i + 1;
652
+ while (j < sqlText.length) if (backslashEscapes && sqlText[j] === "\\") j += 2;
653
+ else if (sqlText[j] === ch && sqlText[j + 1] === ch) j += 2;
654
+ else if (sqlText[j] === ch) {
655
+ j++;
522
656
  break;
657
+ } else j++;
658
+ push(sqlText.slice(i, j), ch === "'" ? "literal" : "identifier");
659
+ i = j;
660
+ continue;
661
+ }
662
+ if (ch === "$" && !IDENTIFIER_CHARACTER.test(code.slice(-1))) {
663
+ const tag = /^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sqlText.slice(i));
664
+ if (tag) {
665
+ const close = sqlText.indexOf(tag[0], i + tag[0].length);
666
+ const end = close === -1 ? sqlText.length : close + tag[0].length;
667
+ push(sqlText.slice(i, end), "literal");
668
+ i = end;
669
+ continue;
523
670
  }
524
671
  }
525
- if (pkDbColumns.length === 0) return { error: `Table "${resolvedTableName}" has no primary key defined.` };
526
- const tableAlias = tables.find((t) => t.name === resolvedTableName)?.alias;
527
- const primaryKeys = pkDbColumns.map((dbCol) => {
528
- const selectCol = selectColumns.find((sc) => sc.column === dbCol && (!sc.table || sc.table === (tableAlias || resolvedTableName)));
529
- return {
530
- dbColumn: dbCol,
531
- resultColumn: selectCol?.alias || selectCol?.column || dbCol
532
- };
533
- });
534
- return {
535
- tableName: resolvedTableName,
536
- primaryKeys
537
- };
538
- } catch (e) {
539
- console.warn("Failed to parse SQL AST:", e);
540
- return { error: `Could not safely parse query for inline editing: ${e instanceof Error ? e.message : String(e)}` };
672
+ code += ch;
673
+ i++;
541
674
  }
675
+ if (code) segments.push({
676
+ text: code,
677
+ kind: "code"
678
+ });
679
+ return segments;
680
+ }
681
+ /**
682
+ * The console's "Format SQL": runs of whitespace become one space, a comma is
683
+ * followed by one, and an `=` standing alone gets one on each side — in the
684
+ * code only.
685
+ *
686
+ * A literal, a quoted identifier and a comment are copied as they are: their
687
+ * text is data, and rewriting `'a,b'` to `'a, b'` changes the value the next
688
+ * Run writes. A line comment keeps the line break that ends it, or it would
689
+ * swallow the code after it. `>=`, `<=`, `!=`, `:=` and `=>` are operators of
690
+ * their own and are left whole.
691
+ */
692
+ function formatSql(sqlText) {
693
+ const segments = splitSql(sqlText);
694
+ return segments.map((segment, index) => {
695
+ if (segment.kind !== "code") return segment.text;
696
+ const tidied = segment.text.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").replace(/(?<![<>!:=~*+\-/%^&|#@?])\s*=\s*(?![=>~*<])/g, " = ");
697
+ return segments[index - 1]?.kind === "line comment" ? tidied.trimStart() : tidied;
698
+ }).join("").trim();
699
+ }
700
+ /**
701
+ * `sqlText` with the console's automatic `LIMIT` on it, or `null` when it
702
+ * takes none (see {@link acceptsAutoLimit}).
703
+ *
704
+ * The limit goes straight after the statement's last token — before a
705
+ * trailing comment, which would otherwise swallow it, and in place of a
706
+ * trailing `;`. A query that computes an aggregate is limited like any other:
707
+ * the limit applies to the rows it returns, so a `count(*)` still counts every
708
+ * row, and a window `count(*) OVER ()` is no longer a way past the cap.
709
+ */
710
+ function withAutoLimit(sqlText, limit) {
711
+ if (!acceptsAutoLimit(sqlText)) return null;
712
+ const segments = splitSql(sqlText);
713
+ const isComment = (segment) => segment.kind === "line comment" || segment.kind === "block comment";
714
+ const isFiller = (segment) => isComment(segment) || segment.kind === "code" && /^[\s;]*$/.test(segment.text);
715
+ let last = segments.length - 1;
716
+ while (last >= 0 && isFiller(segments[last])) last--;
717
+ if (last < 0) return null;
718
+ const lastSegment = segments[last];
719
+ const head = lastSegment.kind === "code" ? lastSegment.text.replace(/[\s;]+$/, "") : lastSegment.text;
720
+ const tail = (lastSegment.kind === "code" ? lastSegment.text.slice(head.length) : "").replace(/;/g, "") + segments.slice(last + 1).map((segment) => segment.kind === "code" ? segment.text.replace(/;/g, "") : segment.text).join("");
721
+ return `${segments.slice(0, last).map((segment) => segment.text).join("")}${head} LIMIT ${limit};${tail.trimEnd()}`;
542
722
  }
543
723
  //#endregion
544
724
  //#region src/components/SQLEditor/ExplainVisualizer.tsx
@@ -744,13 +924,13 @@ var FixedEditorOverlay = ({ displayValue, onSave, onCancel }) => {
744
924
  e.target.value = val;
745
925
  },
746
926
  onBlur: (e) => {
747
- onSave(e.target.value || null);
927
+ onSave(e.target.value);
748
928
  onCancel();
749
929
  },
750
930
  onKeyDown: (e) => {
751
931
  if (e.key === "Enter" && !e.shiftKey) {
752
932
  e.preventDefault();
753
- onSave(e.currentTarget.value || null);
933
+ onSave(e.currentTarget.value);
754
934
  onCancel();
755
935
  }
756
936
  if (e.key === "Escape") onCancel();
@@ -791,7 +971,18 @@ var SQLEditor = () => {
791
971
  return readStoredString(`rebase_sql_selected_role_${client?.baseUrl ? client.baseUrl.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9]/g, "_") : "default"}`) || void 0;
792
972
  });
793
973
  const [availableDatabases, setAvailableDatabases] = useState([]);
974
+ /**
975
+ * The database the app reads and writes — the one the CMS opens a record
976
+ * on. Rows read anywhere else are not that record.
977
+ */
978
+ const [appDatabase, setAppDatabase] = useState(void 0);
794
979
  const [availableRoles, setAvailableRoles] = useState([]);
980
+ /**
981
+ * Who every statement runs as, once a run has shown that the server does
982
+ * not switch roles (`DISABLE_DB_ROLE_SWITCHING`): the role picker then
983
+ * changes nothing, and says so.
984
+ */
985
+ const [connectionOwner, setConnectionOwner] = useState(null);
795
986
  const [isLoadingConfig, setIsLoadingConfig] = useState(true);
796
987
  const [connectionConfigError, setConnectionConfigError] = useState(null);
797
988
  const [tabs, setTabs] = useState(() => {
@@ -805,7 +996,11 @@ var SQLEditor = () => {
805
996
  loading: false,
806
997
  error: null,
807
998
  execTime: null,
808
- lastExecutedSql: null
999
+ lastExecutedSql: null,
1000
+ lastExecutedConnection: null,
1001
+ ranAs: null,
1002
+ lastProvenance: null,
1003
+ notices: []
809
1004
  }));
810
1005
  if (restored.length > 0) return restored;
811
1006
  return [{
@@ -818,7 +1013,11 @@ var SQLEditor = () => {
818
1013
  loading: false,
819
1014
  error: null,
820
1015
  execTime: null,
821
- lastExecutedSql: null
1016
+ lastExecutedSql: null,
1017
+ lastExecutedConnection: null,
1018
+ ranAs: null,
1019
+ lastProvenance: null,
1020
+ notices: []
822
1021
  }];
823
1022
  });
824
1023
  const [activeTabId, setActiveTabId] = useState(() => {
@@ -855,6 +1054,7 @@ var SQLEditor = () => {
855
1054
  if (mounted) {
856
1055
  setAvailableDatabases(dbs);
857
1056
  setAvailableRoles(roles);
1057
+ setAppDatabase(currentDbFromApi);
858
1058
  const loadedDb = readStoredString(`rebase_sql_selected_db_${projectPrefix}`) || void 0;
859
1059
  const loadedRole = readStoredString(`rebase_sql_selected_role_${projectPrefix}`) || void 0;
860
1060
  const initialActiveTabId = readStoredString(`rebase_sql_active_tab_${projectPrefix}`) || "1";
@@ -1031,7 +1231,23 @@ var SQLEditor = () => {
1031
1231
  const [autoLimit, setAutoLimit] = useState(true);
1032
1232
  const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
1033
1233
  const [pendingAction, setPendingAction] = useState(null);
1234
+ /**
1235
+ * The cell being edited, and the text its editor opened with — what
1236
+ * closing the editor unchanged is compared against.
1237
+ */
1034
1238
  const [editingCell, setEditingCell] = useState(null);
1239
+ const isCurrentConnection = useCallback((connection) => connection !== null && connection.database === selectedDatabase && connection.role === selectedRole, [selectedDatabase, selectedRole]);
1240
+ /**
1241
+ * Which stored cell a result cell is — by what the database said about the
1242
+ * last run's columns, and nothing else.
1243
+ */
1244
+ const resolveEdit = useCallback((columnKey) => {
1245
+ if (!activeTab.lastExecutedSql || !activeTab.lastProvenance) return { refusal: {
1246
+ key: "studio_sql_edit_computed_column",
1247
+ params: { column: columnKey }
1248
+ } };
1249
+ return resolveCellEdit(activeTab.lastExecutedSql, activeTab.lastProvenance, columnKey);
1250
+ }, [activeTab.lastExecutedSql, activeTab.lastProvenance]);
1035
1251
  const handleDoubleClick = useCallback((rowIndex, columnKey, initialValue, rowData) => {
1036
1252
  if (!activeTab.lastExecutedSql) {
1037
1253
  snackbarController.open({
@@ -1040,15 +1256,22 @@ var SQLEditor = () => {
1040
1256
  });
1041
1257
  return;
1042
1258
  }
1043
- const resolution = determineTableAndPK(activeTab.lastExecutedSql, columnKey, schemas);
1044
- if (resolution.error || !resolution.primaryKeys || resolution.primaryKeys.length === 0) {
1259
+ if (!isCurrentConnection(activeTab.lastExecutedConnection)) {
1260
+ snackbarController.open({
1261
+ type: "error",
1262
+ message: t("studio_sql_cannot_edit_other_connection")
1263
+ });
1264
+ return;
1265
+ }
1266
+ const resolution = resolveEdit(columnKey);
1267
+ if (!resolution.target) {
1045
1268
  snackbarController.open({
1046
1269
  type: "error",
1047
- message: resolution.error || t("studio_sql_cannot_resolve_table")
1270
+ message: t(resolution.refusal.key, resolution.refusal.params)
1048
1271
  });
1049
1272
  return;
1050
1273
  }
1051
- const missingPKs = resolution.primaryKeys.filter((pk) => rowData[pk.resultColumn] === void 0 || rowData[pk.resultColumn] === null);
1274
+ const missingPKs = resolution.target.key.filter((pk) => rowData[pk.resultColumn] === void 0 || rowData[pk.resultColumn] === null);
1052
1275
  if (missingPKs.length > 0) {
1053
1276
  snackbarController.open({
1054
1277
  type: "error",
@@ -1063,61 +1286,63 @@ var SQLEditor = () => {
1063
1286
  });
1064
1287
  }, [
1065
1288
  activeTab.lastExecutedSql,
1066
- schemas,
1067
- snackbarController
1289
+ activeTab.lastExecutedConnection,
1290
+ isCurrentConnection,
1291
+ resolveEdit,
1292
+ snackbarController,
1293
+ t
1068
1294
  ]);
1069
- const handleCellSave = useCallback(async (newValue, rowData, columnKey, rowIndex) => {
1295
+ const handleCellSave = useCallback(async (text, rowData, columnKey, rowIndex) => {
1070
1296
  if (!editingCell || !activeTab.lastExecutedSql) return;
1071
1297
  setEditingCell(null);
1072
- if (newValue === editingCell.initialValue) return;
1073
- const resolution = determineTableAndPK(activeTab.lastExecutedSql, columnKey, schemas);
1074
- if (resolution.error || !resolution.tableName || !resolution.primaryKeys || resolution.primaryKeys.length === 0) {
1298
+ if (text === editingCell.initialValue) return;
1299
+ const newValue = text === "" ? null : text;
1300
+ const connection = activeTab.lastExecutedConnection;
1301
+ if (!isCurrentConnection(connection)) {
1075
1302
  snackbarController.open({
1076
1303
  type: "error",
1077
- message: resolution.error || "Resolution failed."
1304
+ message: t("studio_sql_cannot_edit_other_connection")
1078
1305
  });
1079
1306
  return;
1080
1307
  }
1081
- const tableName = resolution.tableName;
1308
+ const resolution = resolveEdit(columnKey);
1309
+ if (!resolution.target) {
1310
+ snackbarController.open({
1311
+ type: "error",
1312
+ message: t(resolution.refusal.key, resolution.refusal.params)
1313
+ });
1314
+ return;
1315
+ }
1316
+ const target = resolution.target;
1082
1317
  const formatValue = (val) => {
1083
1318
  if (val === null || val === void 0) return "NULL";
1084
1319
  if (typeof val === "number") return val;
1085
1320
  if (typeof val === "boolean") return val ? "TRUE" : "FALSE";
1086
1321
  return `'${String(val).replace(/'/g, "''")}'`;
1087
1322
  };
1088
- const resolveDbColumnName = (resultColKey) => {
1089
- try {
1090
- const ast = parseFirst(activeTab.lastExecutedSql);
1091
- if (ast.type === "select" && ast.columns) {
1092
- for (const col of ast.columns) if (col.expr?.type === "ref") {
1093
- const alias = col.alias?.name;
1094
- const colName = col.expr.name;
1095
- if (alias === resultColKey || !alias && colName === resultColKey) return colName;
1096
- }
1097
- }
1098
- } catch {}
1099
- return resultColKey;
1100
- };
1101
- const dbColumnName = resolveDbColumnName(columnKey);
1102
- const whereConditions = resolution.primaryKeys.map((pk) => `"${pk.dbColumn}" = ${formatValue(rowData[pk.resultColumn])}`).join(" AND ");
1103
- const updateSql = `UPDATE "${tableName}" SET "${dbColumnName}" = ${formatValue(newValue)} WHERE ${whereConditions};`;
1323
+ const whereConditions = target.key.map((pk) => `${quoteIdentifier(pk.column)} = ${formatValue(rowData[pk.resultColumn])}`).join(" AND ");
1324
+ const updateSql = `UPDATE ${quoteTableName(target.table, target.schema)} SET ${quoteIdentifier(target.column)} = ${formatValue(newValue)} WHERE ${whereConditions};`;
1104
1325
  try {
1105
- if (databaseAdmin?.executeSql) {
1106
- await databaseAdmin.executeSql(updateSql, {
1107
- database: selectedDatabase,
1108
- role: selectedRole
1109
- });
1110
- const newResults = [...activeTab.results || []];
1111
- if (newResults[rowIndex]) newResults[rowIndex] = {
1112
- ...newResults[rowIndex],
1113
- [columnKey]: newValue
1114
- };
1115
- updateActiveTab({ results: newResults });
1326
+ if (!(await databaseAdmin?.runSqlScript?.(updateSql, {
1327
+ database: connection.database,
1328
+ role: connection.role
1329
+ }))?.rowCount) {
1116
1330
  snackbarController.open({
1117
- type: "success",
1118
- message: t("studio_sql_row_updated")
1331
+ type: "error",
1332
+ message: t("studio_sql_update_no_row")
1119
1333
  });
1334
+ return;
1120
1335
  }
1336
+ const newResults = [...activeTab.results || []];
1337
+ if (newResults[rowIndex]) newResults[rowIndex] = {
1338
+ ...newResults[rowIndex],
1339
+ [columnKey]: newValue
1340
+ };
1341
+ updateActiveTab({ results: newResults });
1342
+ snackbarController.open({
1343
+ type: "success",
1344
+ message: t("studio_sql_row_updated")
1345
+ });
1121
1346
  } catch (e) {
1122
1347
  snackbarController.open({
1123
1348
  type: "error",
@@ -1126,14 +1351,15 @@ var SQLEditor = () => {
1126
1351
  }
1127
1352
  }, [
1128
1353
  editingCell,
1129
- schemas,
1354
+ resolveEdit,
1130
1355
  activeTab.lastExecutedSql,
1356
+ activeTab.lastExecutedConnection,
1131
1357
  activeTab.results,
1358
+ isCurrentConnection,
1132
1359
  databaseAdmin,
1133
1360
  updateActiveTab,
1134
1361
  snackbarController,
1135
- selectedDatabase,
1136
- selectedRole
1362
+ t
1137
1363
  ]);
1138
1364
  const [columnWidths, setColumnWidths] = useState(() => {
1139
1365
  return readStoredJson(`rebase_sql_column_widths_${client?.baseUrl ? client.baseUrl.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9]/g, "_") : "default"}`, {
@@ -1200,7 +1426,11 @@ var SQLEditor = () => {
1200
1426
  loading: false,
1201
1427
  error: null,
1202
1428
  execTime: null,
1203
- lastExecutedSql: null
1429
+ lastExecutedSql: null,
1430
+ lastExecutedConnection: null,
1431
+ ranAs: null,
1432
+ lastProvenance: null,
1433
+ notices: []
1204
1434
  }]);
1205
1435
  setActiveTabId(newId);
1206
1436
  };
@@ -1229,15 +1459,28 @@ var SQLEditor = () => {
1229
1459
  });
1230
1460
  }, [activeTab.sql, projectPrefix]);
1231
1461
  const handlePrettify = () => {
1232
- const formatted = activeTab.sql.replace(/\s+/g, " ").replace(/\s?,\s?/g, ", ").replace(/\s?=\s?/g, " = ").trim();
1233
- setSql(formatted);
1462
+ setSql(formatSql(activeTab.sql));
1234
1463
  };
1464
+ /** The editor, for the selection the toolbar's buttons act on. */
1465
+ const editorHandle = useRef(null);
1235
1466
  const handleExplain = async () => {
1236
- const explainSql = `EXPLAIN (FORMAT JSON, ANALYZE) ${activeTab.sql}`;
1467
+ const explainSql = buildExplainSql(editorHandle.current?.getSelectedText() ?? activeTab.sql);
1468
+ if (!explainSql) {
1469
+ updateActiveTab({
1470
+ results: null,
1471
+ error: t("studio_sql_explain_single_statement")
1472
+ });
1473
+ return;
1474
+ }
1237
1475
  updateActiveTab({
1238
1476
  loading: true,
1239
1477
  error: null,
1240
- results: null
1478
+ results: null,
1479
+ lastExecutedSql: null,
1480
+ lastExecutedConnection: null,
1481
+ ranAs: null,
1482
+ lastProvenance: null,
1483
+ notices: []
1241
1484
  });
1242
1485
  const start = performance.now();
1243
1486
  try {
@@ -1259,31 +1502,39 @@ var SQLEditor = () => {
1259
1502
  }
1260
1503
  };
1261
1504
  const executeRun = useCallback(async (sqlOverride) => {
1262
- let sqlToRun = sqlOverride || activeTab.sql;
1263
- const upperSql = sqlToRun.toUpperCase();
1264
- const isAggregate = /\b(COUNT|SUM|AVG|MIN|MAX)\s*\(/i.test(sqlToRun);
1265
- const isExplain = /\bEXPLAIN\b/i.test(sqlToRun);
1266
- if (autoLimit && upperSql.includes("SELECT") && !upperSql.includes("LIMIT") && !isAggregate && !isExplain) {
1267
- sqlToRun = sqlToRun.trim().replace(/;$/, "");
1268
- sqlToRun = `${sqlToRun} LIMIT 1000;`;
1269
- }
1505
+ const written = sqlOverride || activeTab.sql;
1506
+ const sqlToRun = (autoLimit ? withAutoLimit(written, 1e3) : null) ?? written;
1270
1507
  updateActiveTab({
1271
1508
  loading: true,
1272
1509
  error: null,
1273
- results: null
1510
+ results: null,
1511
+ notices: []
1274
1512
  });
1275
1513
  const start = performance.now();
1276
1514
  try {
1277
- if (databaseAdmin?.executeSql) {
1278
- const result = await databaseAdmin.executeSql(sqlToRun, {
1515
+ if (databaseAdmin?.runSqlScript || databaseAdmin?.executeSql) {
1516
+ const connection = {
1279
1517
  database: selectedDatabase,
1280
1518
  role: selectedRole
1281
- });
1519
+ };
1520
+ const run = databaseAdmin.runSqlScript ? await databaseAdmin.runSqlScript(sqlToRun, connection) : void 0;
1521
+ const rows = run ? run.rows : await databaseAdmin.executeSql(sqlToRun, connection);
1282
1522
  updateActiveTab({
1283
- results: result,
1523
+ results: rows,
1284
1524
  execTime: Math.round(performance.now() - start),
1285
- lastExecutedSql: sqlToRun
1525
+ lastExecutedSql: sqlToRun,
1526
+ lastExecutedConnection: connection,
1527
+ ranAs: run?.effectiveRole ?? null,
1528
+ lastProvenance: run ? {
1529
+ columns: run.columns,
1530
+ tables: run.tables
1531
+ } : {
1532
+ columns: Object.keys(rows[0] ?? {}).map((name) => ({ name })),
1533
+ tables: []
1534
+ },
1535
+ notices: run?.notices ?? []
1286
1536
  });
1537
+ if (run?.effectiveRole && connection.role) setConnectionOwner(run.effectiveRole !== connection.role ? run.effectiveRole : null);
1287
1538
  if (history[history.length - 1] !== activeTab.sql) saveHistory([...history, activeTab.sql]);
1288
1539
  } else updateActiveTab({ error: t("studio_sql_execution_not_supported") });
1289
1540
  } catch (e) {
@@ -1304,14 +1555,7 @@ var SQLEditor = () => {
1304
1555
  const handleRun = useCallback(async (selectedText) => {
1305
1556
  const sqlTarget = selectedText || activeTab.sql;
1306
1557
  if (!sqlTarget.trim()) return;
1307
- const hasDestructive = [
1308
- "DELETE",
1309
- "DROP",
1310
- "TRUNCATE",
1311
- "UPDATE"
1312
- ].some((kw) => sqlTarget.toUpperCase().includes(kw));
1313
- const hasWhere = sqlTarget.toUpperCase().includes("WHERE");
1314
- if (hasDestructive && (!hasWhere || sqlTarget.toUpperCase().includes("DROP") || sqlTarget.toUpperCase().includes("TRUNCATE"))) {
1558
+ if (needsDestructiveConfirmation(sqlTarget)) {
1315
1559
  setPendingAction(() => () => executeRun(selectedText));
1316
1560
  setIsConfirmDialogOpen(true);
1317
1561
  return;
@@ -1320,12 +1564,13 @@ var SQLEditor = () => {
1320
1564
  }, [activeTab.sql, executeRun]);
1321
1565
  useEffect(() => {
1322
1566
  const handleKeyDown = (e) => {
1567
+ if (isKeyHandled(e)) return;
1323
1568
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
1324
1569
  const activeElement = document.activeElement;
1325
1570
  const isInput = activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA";
1326
1571
  if (!activeElement?.className?.includes("monaco-mouse-cursor-text") && !isInput) {
1327
1572
  e.preventDefault();
1328
- handleRun();
1573
+ handleRun(editorHandle.current?.getSelectedText());
1329
1574
  }
1330
1575
  }
1331
1576
  };
@@ -1468,14 +1713,8 @@ var SQLEditor = () => {
1468
1713
  });
1469
1714
  const savedWidths = columnWidths[activeTab.sql] || {};
1470
1715
  const resultColumnKeys = Object.keys(results[0]);
1471
- const actionableCollections = (() => {
1472
- if (!activeTab.lastExecutedSql || !collectionRegistry.collections) return [];
1473
- try {
1474
- return resolveQueryCollections(activeTab.lastExecutedSql, schemas, collectionRegistry.collections, resultColumnKeys);
1475
- } catch {
1476
- return [];
1477
- }
1478
- })().filter((mc) => mc.pkColumn && resultColumnKeys.includes(mc.pkColumn));
1716
+ const readFromAppDatabase = activeTab.lastExecutedConnection !== null && (activeTab.lastExecutedConnection.database === void 0 || activeTab.lastExecutedConnection.database === appDatabase);
1717
+ const actionableCollections = activeTab.lastProvenance && collectionRegistry.collections && readFromAppDatabase ? resolveQueryCollections(activeTab.lastProvenance, collectionRegistry.collections).filter((mc) => resultColumnKeys.includes(mc.pkColumn)) : [];
1479
1718
  const getRowEntityActions = (rowData) => {
1480
1719
  if (!rowData) return [];
1481
1720
  return actionableCollections.filter((mc) => rowData[mc.pkColumn] != null).map((mc) => ({
@@ -1547,6 +1786,10 @@ var SQLEditor = () => {
1547
1786
  }),
1548
1787
  children: /* @__PURE__ */ jsx(IconButton, {
1549
1788
  size: "small",
1789
+ "aria-label": t("studio_sql_edit_entity", {
1790
+ name: ra.collection.collection.name,
1791
+ id: String(ra.entityId)
1792
+ }),
1550
1793
  className: "text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors",
1551
1794
  onClick: (e) => {
1552
1795
  e.stopPropagation();
@@ -1603,7 +1846,7 @@ var SQLEditor = () => {
1603
1846
  children: /* @__PURE__ */ jsx("div", {
1604
1847
  className: "truncate flex-grow",
1605
1848
  title: displayValue,
1606
- children: displayValue === "" ? /* @__PURE__ */ jsx("span", {
1849
+ children: value === null || value === void 0 ? /* @__PURE__ */ jsx("span", {
1607
1850
  className: "text-text-disabled dark:text-text-disabled-dark italic text-[11px]",
1608
1851
  children: "NULL"
1609
1852
  }) : displayValue
@@ -1753,6 +1996,7 @@ var SQLEditor = () => {
1753
1996
  children: /* @__PURE__ */ jsx(IconButton, {
1754
1997
  size: "small",
1755
1998
  onClick: handlePrettify,
1999
+ "aria-label": t("studio_sql_format_sql"),
1756
2000
  children: /* @__PURE__ */ jsx(MenuIcon, { size: iconSize.smallest })
1757
2001
  })
1758
2002
  }),
@@ -1833,7 +2077,7 @@ var SQLEditor = () => {
1833
2077
  className: "mr-1.5 text-text-disabled dark:text-text-disabled-dark"
1834
2078
  }), /* @__PURE__ */ jsx("span", {
1835
2079
  className: "max-w-[160px] truncate",
1836
- children: isLoadingConfig ? "..." : `${selectedDatabase || t("studio_sql_select_db")}${selectedRole ? ` (${selectedRole})` : ""}`
2080
+ children: isLoadingConfig ? "..." : `${selectedDatabase || t("studio_sql_select_db")}${connectionOwner ?? selectedRole ? ` (${connectionOwner ?? selectedRole})` : ""}`
1837
2081
  })]
1838
2082
  }),
1839
2083
  children: /* @__PURE__ */ jsxs("div", {
@@ -1866,8 +2110,13 @@ var SQLEditor = () => {
1866
2110
  children: t("studio_sql_role")
1867
2111
  })
1868
2112
  }),
2113
+ connectionOwner && /* @__PURE__ */ jsx("div", {
2114
+ className: "px-3 py-1.5 text-xs text-text-secondary dark:text-text-secondary-dark max-w-[240px] break-words",
2115
+ children: t("studio_sql_role_switching_disabled", { role: connectionOwner })
2116
+ }),
1869
2117
  availableRoles.map((role) => /* @__PURE__ */ jsxs(MenuItem, {
1870
2118
  dense: true,
2119
+ disabled: connectionOwner !== null,
1871
2120
  onClick: () => handleRoleChange(role),
1872
2121
  className: cls("text-xs", selectedRole === role && "text-primary dark:text-primary-dark"),
1873
2122
  children: [role, role === "postgres" ? " " + t("studio_sql_collections_label") : ""]
@@ -1876,7 +2125,7 @@ var SQLEditor = () => {
1876
2125
  })
1877
2126
  }),
1878
2127
  /* @__PURE__ */ jsxs(Button, {
1879
- onClick: () => handleRun(),
2128
+ onClick: () => handleRun(editorHandle.current?.getSelectedText()),
1880
2129
  disabled: loading,
1881
2130
  size: "small",
1882
2131
  color: "primary",
@@ -1898,6 +2147,7 @@ var SQLEditor = () => {
1898
2147
  firstPanel: /* @__PURE__ */ jsx("div", {
1899
2148
  className: "h-full w-full relative flex flex-col min-h-0",
1900
2149
  children: /* @__PURE__ */ jsx(MonacoEditor, {
2150
+ ref: editorHandle,
1901
2151
  value: sql,
1902
2152
  onChange: (v) => setSql(v || ""),
1903
2153
  onRun: handleRun,
@@ -1906,17 +2156,39 @@ var SQLEditor = () => {
1906
2156
  }),
1907
2157
  secondPanel: /* @__PURE__ */ jsxs("div", {
1908
2158
  className: "h-full w-full flex flex-col bg-surface-well overflow-hidden min-h-0",
1909
- children: [/* @__PURE__ */ jsx("div", {
1910
- className: cls("p-2 px-4 bg-surface-raised border-b shrink-0 flex items-center", defaultBorderMixin),
1911
- children: /* @__PURE__ */ jsx(Typography, {
1912
- variant: "caption",
1913
- className: "font-semibold text-text-disabled dark:text-text-disabled-dark uppercase tracking-widest text-[10px]",
1914
- children: t("studio_sql_query_results")
2159
+ children: [
2160
+ /* @__PURE__ */ jsx("div", {
2161
+ className: cls("p-2 px-4 bg-surface-raised border-b shrink-0 flex items-center", defaultBorderMixin),
2162
+ children: /* @__PURE__ */ jsx(Typography, {
2163
+ variant: "caption",
2164
+ className: "font-semibold text-text-disabled dark:text-text-disabled-dark uppercase tracking-widest text-[10px]",
2165
+ children: t("studio_sql_query_results")
2166
+ })
2167
+ }),
2168
+ !loading && results && activeTab.ranAs && activeTab.lastExecutedConnection?.role && activeTab.ranAs !== activeTab.lastExecutedConnection.role && /* @__PURE__ */ jsx("div", {
2169
+ className: "px-4 pt-2 shrink-0",
2170
+ children: /* @__PURE__ */ jsx(Alert, {
2171
+ color: "warning",
2172
+ size: "small",
2173
+ children: t("studio_sql_ran_as_other_role", {
2174
+ ranAs: activeTab.ranAs,
2175
+ role: activeTab.lastExecutedConnection.role
2176
+ })
2177
+ })
2178
+ }),
2179
+ !loading && activeTab.notices.length > 0 && /* @__PURE__ */ jsx("div", {
2180
+ className: "px-4 pt-2 flex flex-col gap-1 shrink-0",
2181
+ children: activeTab.notices.map((notice, index) => /* @__PURE__ */ jsx(Alert, {
2182
+ color: "warning",
2183
+ size: "small",
2184
+ children: `${notice.severity}: ${notice.message}`
2185
+ }, index))
2186
+ }),
2187
+ /* @__PURE__ */ jsx("div", {
2188
+ className: "flex-grow flex flex-col min-h-0 overflow-hidden",
2189
+ children: renderResults()
1915
2190
  })
1916
- }), /* @__PURE__ */ jsx("div", {
1917
- className: "flex-grow flex flex-col min-h-0 overflow-hidden",
1918
- children: renderResults()
1919
- })]
2191
+ ]
1920
2192
  })
1921
2193
  })]
1922
2194
  })
@@ -1974,4 +2246,4 @@ var SQLEditor = () => {
1974
2246
  //#endregion
1975
2247
  export { SQLEditor };
1976
2248
 
1977
- //# sourceMappingURL=SQLEditor-sdtoZv5F.js.map
2249
+ //# sourceMappingURL=SQLEditor-1An7w0j6.js.map