fix(gbase8s): make view source saves safer

This commit is contained in:
zipg 2026-07-07 01:50:53 +08:00 committed by GitHub
parent 15b86ad6c9
commit 668919a7c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1343 additions and 129 deletions

View File

@ -7,20 +7,26 @@ public final class ObjectSource {
private String object_type;
private String schema;
private String source;
private boolean editable;
public ObjectSource() {
this("", "", null, "");
this("", "", null, "", true);
}
public ObjectSource(String name, String object_type, String source) {
this(name, object_type, null, source);
this(name, object_type, null, source, true);
}
public ObjectSource(String name, String object_type, String schema, String source) {
this(name, object_type, schema, source, true);
}
public ObjectSource(String name, String object_type, String schema, String source, boolean editable) {
this.name = name;
this.object_type = object_type;
this.schema = schema;
this.source = source;
this.editable = editable;
}
public String getName() {
@ -39,6 +45,10 @@ public final class ObjectSource {
return source;
}
public boolean isEditable() {
return editable;
}
public void setName(String name) {
this.name = name;
}
@ -55,6 +65,10 @@ public final class ObjectSource {
this.source = source;
}
public void setEditable(boolean editable) {
this.editable = editable;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
@ -63,12 +77,13 @@ public final class ObjectSource {
return Objects.equals(name, that.name)
&& Objects.equals(object_type, that.object_type)
&& Objects.equals(schema, that.schema)
&& Objects.equals(source, that.source);
&& Objects.equals(source, that.source)
&& editable == that.editable;
}
@Override
public int hashCode() {
return Objects.hash(name, object_type, schema, source);
return Objects.hash(name, object_type, schema, source, editable);
}
@Override
@ -77,6 +92,7 @@ public final class ObjectSource {
+ ", object_type=" + object_type
+ ", schema=" + schema
+ ", source=" + source
+ ", editable=" + editable
+ ")";
}
}

View File

@ -2,11 +2,13 @@ package com.dbx.agent.gbase8s;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.QueryResult;
import com.dbx.agent.TableInfo;
import java.sql.Connection;
@ -15,6 +17,7 @@ import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
@ -195,6 +198,73 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
return queryConstrainedTables(schema, normalized);
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
try {
Connection conn = requireConnection();
String owner = trim(schema);
Set<Integer> primaryKeyColumns = getPrimaryKeyColumnNumbers(conn, owner, table);
List<Object> args = new ArrayList<>();
args.add(table);
StringBuilder sql = new StringBuilder("""
SELECT c.colname, c.coltype, c.colno, c.collength
FROM syscolumns c
JOIN systables t ON t.tabid = c.tabid
WHERE t.tabid >= 100 AND t.tabname = ?
""".stripIndent().trim());
if (!owner.isEmpty()) {
sql.append(" AND t.owner = ?");
args.add(owner);
}
sql.append(" ORDER BY c.colno");
List<ColumnInfo> result = new ArrayList<>();
try (PreparedStatement stmt = conn.prepareStatement(sql.toString())) {
bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String name = trim(rs.getString("colname"));
int coltype = rs.getInt("coltype");
int baseType = baseColType(coltype);
int length = rs.getInt("collength");
result.add(new ColumnInfo(
name,
mapColType(baseType),
(coltype & 256) == 0,
null,
primaryKeyColumns.contains(rs.getInt("colno")),
null,
null,
numericPrecision(baseType, length),
numericScale(baseType, length),
characterMaximumLength(baseType, length)
));
}
}
}
return result;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public ObjectSource getObjectSource(String schema, String name, String objectType) {
String normalizedType = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT);
if (!"VIEW".equals(normalizedType)) {
throw new UnsupportedOperationException("Object source is not supported");
}
return new ObjectSource(name, "VIEW", emptyToNull(trim(schema)), viewSource(schema, name), isEditableView(schema, name));
}
@Override
public String getTableDdl(String schema, String table) {
if ("VIEW".equals(tableType(schema, table))) {
return viewSource(schema, table);
}
return super.getTableDdl(schema, table);
}
private List<TableInfo> queryConstrainedTables(String schema, MetadataListConstraints constraints) {
if (!constraints.includesTableLikeTypes()) {
return List.of();
@ -336,6 +406,211 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
return "V".equalsIgnoreCase(trim(tabtype)) ? "VIEW" : "TABLE";
}
private String tableType(String schema, String table) {
try {
String owner = trim(schema);
List<Object> args = new ArrayList<>();
args.add(table);
StringBuilder sql = new StringBuilder("SELECT tabtype FROM systables WHERE tabid >= 100 AND tabname = ?");
if (!owner.isEmpty()) {
sql.append(" AND owner = ?");
args.add(owner);
}
try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) {
bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
return rs.next() ? tableType(rs.getString("tabtype")) : "";
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private String viewSource(String schema, String name) {
try {
String owner = trim(schema);
List<Object> args = new ArrayList<>();
args.add(name);
StringBuilder sql = new StringBuilder("""
SELECT v.viewtext
FROM sysviews v
JOIN systables t ON t.tabid = v.tabid
WHERE t.tabname = ?
""".stripIndent().trim());
if (!owner.isEmpty()) {
sql.append(" AND t.owner = ?");
args.add(owner);
}
sql.append(" ORDER BY v.seqno");
StringBuilder source = new StringBuilder();
try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) {
bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String chunk = rs.getString("viewtext");
source.append(chunk == null ? "" : chunk);
}
}
}
String result = stripTrailing(source.toString());
if (result.isEmpty()) {
throw new IllegalArgumentException("View source not found: " + name);
}
return result;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private boolean isEditableView(String schema, String name) {
try {
String owner = trim(schema);
List<Object> args = new ArrayList<>();
args.add(name);
StringBuilder sql = new StringBuilder("""
SELECT t.tabid, t.owner,
(SELECT v.tabid FROM systables v WHERE UPPER(TRIM(v.tabname)) = 'VERSION') AS system_boundary_tabid
FROM systables t
WHERE t.tabtype = 'V' AND t.tabname = ?
""".stripIndent().trim());
if (!owner.isEmpty()) {
sql.append(" AND t.owner = ?");
args.add(owner);
}
try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) {
bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) {
return true;
}
int systemBoundaryTabid = rs.getInt("system_boundary_tabid");
Integer boundary = rs.wasNull() ? null : systemBoundaryTabid;
return !isSystemCatalogView(rs.getInt("tabid"), rs.getString("owner"), boundary);
}
}
} catch (Exception ignored) {
return true;
}
}
private static boolean isSystemCatalogView(int tabid, String owner, Integer systemBoundaryTabid) {
if (!"gbasedbt".equalsIgnoreCase(trim(owner))) {
return false;
}
// GBase 8s marks system catalog tables before/at VERSION; avoid a fixed tabid threshold
// so ordinary gbasedbt-owned user views are still editable.
if (systemBoundaryTabid != null && systemBoundaryTabid > 0) {
return tabid <= systemBoundaryTabid;
}
return tabid < 100;
}
public static String mapColType(int coltype) {
return switch (baseColType(coltype)) {
case 0 -> "CHAR";
case 1 -> "SMALLINT";
case 2 -> "INTEGER";
case 3 -> "FLOAT";
case 4 -> "SMALLFLOAT";
case 5 -> "DECIMAL";
case 6 -> "SERIAL";
case 7 -> "DATE";
case 8 -> "MONEY";
case 9 -> "NULL";
case 10 -> "DATETIME";
case 11 -> "BYTE";
case 12 -> "TEXT";
case 13 -> "VARCHAR";
case 14 -> "INTERVAL";
case 15 -> "NCHAR";
case 16 -> "NVARCHAR";
case 17 -> "INT8";
case 18 -> "SERIAL8";
case 19 -> "SET";
case 20 -> "MULTISET";
case 21 -> "LIST";
case 22 -> "ROW";
case 23 -> "COLLECTION";
case 40 -> "LVARCHAR";
case 41 -> "BOOLEAN";
case 43, 52 -> "BIGINT";
case 44, 53 -> "BIGSERIAL";
default -> "UNKNOWN(" + baseColType(coltype) + ")";
};
}
public static Set<Integer> primaryKeyColumnNumbers(List<Integer> parts) {
Set<Integer> result = new HashSet<>();
for (Integer part : parts) {
if (part == null) {
continue;
}
int value = Math.abs(part);
if (value > 0) {
result.add(value);
}
}
return result;
}
private Set<Integer> getPrimaryKeyColumnNumbers(Connection conn, String owner, String table) throws Exception {
List<Object> args = new ArrayList<>();
args.add(table);
StringBuilder sql = new StringBuilder("""
SELECT i.part1, i.part2, i.part3, i.part4, i.part5, i.part6, i.part7, i.part8,
i.part9, i.part10, i.part11, i.part12, i.part13, i.part14, i.part15, i.part16
FROM sysconstraints c
JOIN sysindexes i ON i.idxname = c.idxname AND i.tabid = c.tabid
JOIN systables t ON t.tabid = c.tabid
WHERE t.tabname = ? AND c.constrtype = 'P'
""".stripIndent().trim());
if (!owner.isEmpty()) {
sql.append(" AND t.owner = ?");
args.add(owner);
}
try (PreparedStatement stmt = conn.prepareStatement(sql.toString())) {
bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) {
return Collections.emptySet();
}
List<Integer> parts = new ArrayList<>();
for (int index = 1; index <= 16; index += 1) {
int value = rs.getInt(index);
parts.add(rs.wasNull() ? null : value);
}
return primaryKeyColumnNumbers(parts);
}
}
}
private static int baseColType(int coltype) {
return coltype % 256;
}
private static Integer numericPrecision(int baseType, int length) {
if (baseType == 5 || baseType == 8) {
return (length >> 8) & 0xff;
}
return null;
}
private static Integer numericScale(int baseType, int length) {
if (baseType == 5 || baseType == 8) {
return length & 0xff;
}
return null;
}
private static Integer characterMaximumLength(int baseType, int length) {
return switch (baseType) {
case 0, 13, 15, 16, 40 -> length;
default -> null;
};
}
private static void appendGbase8sTableTypePredicate(StringBuilder sql, MetadataListConstraints constraints) {
if (!constraints.hasObjectTypes()) {
sql.append(" AND tabtype IN ('T', 'V')");
@ -372,6 +647,14 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
return value == null ? "" : value.trim();
}
private static String emptyToNull(String value) {
return value.isEmpty() ? null : value;
}
private static String stripTrailing(String value) {
return value == null ? "" : value.stripTrailing();
}
private String currentCatalog() {
try {
return trim(requireConnection().getCatalog());

View File

@ -1,7 +1,9 @@
package com.dbx.agent.gbase8s;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.TableInfo;
import com.dbx.agent.test.TestSupport;
import org.junit.jupiter.api.Assertions;
@ -14,7 +16,9 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
class Gbase8sAgentTest {
@Test
@ -131,10 +135,149 @@ class Gbase8sAgentTest {
Assertions.assertTrue(sql.get(0).contains("UPPER(tabname) LIKE ?"), sql.get(0));
}
private static Connection preparedConnection(List<String> sql, ResultSet resultSet) {
@Test
void extractsPrimaryKeyColumnNumbersFromGbase8sIndexParts() {
Assertions.assertEquals(
Set.of(1, 3, 5),
Gbase8sAgent.primaryKeyColumnNumbers(Arrays.asList(1, -3, 0, 5, null))
);
}
@Test
void getColumnsUsesGbase8sSystemCatalog() {
List<String> sql = new ArrayList<>();
Gbase8sAgent agent = new Gbase8sAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(
sql,
resultSet(
new String[]{"part1", "part2", "part3", "part4", "part5", "part6", "part7", "part8", "part9", "part10", "part11", "part12", "part13", "part14", "part15", "part16"},
new Object[][]{
{1, 0, null, null, null, null, null, null, null, null, null, null, null, null, null, null}
}
),
resultSet(
new String[]{"colname", "coltype", "colno", "collength"},
new Object[][]{
{"product_id", 258, 1, 4},
{"sku", 13, 2, 40},
{"price", 5, 3, 3074}
}
)
));
List<ColumnInfo> columns = agent.getColumns("root", "products");
Assertions.assertEquals(2, sql.size());
Assertions.assertTrue(sql.get(0).contains("FROM sysconstraints"), sql.get(0));
Assertions.assertTrue(sql.get(0).contains("t.owner = ?"), sql.get(0));
Assertions.assertTrue(sql.get(1).contains("FROM syscolumns"), sql.get(1));
Assertions.assertTrue(sql.get(1).contains("t.owner = ?"), sql.get(1));
Assertions.assertEquals(3, columns.size());
Assertions.assertEquals("product_id", columns.get(0).getName());
Assertions.assertEquals("INTEGER", columns.get(0).getData_type());
Assertions.assertFalse(columns.get(0).getIs_nullable());
Assertions.assertTrue(columns.get(0).getIs_primary_key());
Assertions.assertEquals("VARCHAR", columns.get(1).getData_type());
Assertions.assertEquals(40, columns.get(1).getCharacter_maximum_length());
Assertions.assertEquals("DECIMAL", columns.get(2).getData_type());
Assertions.assertEquals(12, columns.get(2).getNumeric_precision());
Assertions.assertEquals(2, columns.get(2).getNumeric_scale());
}
@Test
void getObjectSourceUsesGbase8sViewCatalog() {
List<String> sql = new ArrayList<>();
Gbase8sAgent agent = new Gbase8sAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(
sql,
resultSet(
new String[]{"viewtext"},
new Object[][]{
{"create view \"gbasedbt\".demo_view as select "},
{"* from products; "}
}
),
resultSet(
new String[]{"tabid", "owner", "system_boundary_tabid"},
new Object[][]{
{1000, "gbasedbt", 614}
}
)
));
ObjectSource source = agent.getObjectSource("gbasedbt", "demo_view", "VIEW");
Assertions.assertEquals("demo_view", source.getName());
Assertions.assertEquals("VIEW", source.getObject_type());
Assertions.assertEquals("gbasedbt", source.getSchema());
Assertions.assertEquals("create view \"gbasedbt\".demo_view as select * from products;", source.getSource());
Assertions.assertTrue(source.isEditable());
Assertions.assertEquals(2, sql.size());
Assertions.assertTrue(sql.get(0).contains("FROM sysviews"), sql.get(0));
Assertions.assertTrue(sql.get(0).contains("t.owner = ?"), sql.get(0));
Assertions.assertTrue(sql.get(0).contains("ORDER BY v.seqno"), sql.get(0));
Assertions.assertTrue(sql.get(1).contains("system_boundary_tabid"), sql.get(1));
}
@Test
void getObjectSourceMarksGbase8sSystemViewsReadOnly() {
Gbase8sAgent agent = new Gbase8sAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(
new ArrayList<>(),
resultSet(
new String[]{"viewtext"},
new Object[][]{
{"create view \"gbasedbt\".dba_db_links as select * from user_db_links;"}
}
),
resultSet(
new String[]{"tabid", "owner", "system_boundary_tabid"},
new Object[][]{
{614, "gbasedbt", 614}
}
)
));
ObjectSource source = agent.getObjectSource("gbasedbt", "dba_db_links", "VIEW");
Assertions.assertFalse(source.isEditable());
}
@Test
void getTableDdlReturnsViewSourceForGbase8sViews() {
List<String> sql = new ArrayList<>();
Gbase8sAgent agent = new Gbase8sAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(
sql,
resultSet(
new String[]{"tabtype"},
new Object[][]{
{"V"}
}
),
resultSet(
new String[]{"viewtext"},
new Object[][]{
{"create view demo_view as select 1 as id; "}
}
)
));
String ddl = agent.getTableDdl("gbasedbt", "demo_view");
Assertions.assertEquals("create view demo_view as select 1 as id;", ddl);
Assertions.assertEquals(2, sql.size());
Assertions.assertTrue(sql.get(0).contains("SELECT tabtype FROM systables"), sql.get(0));
Assertions.assertTrue(sql.get(1).contains("FROM sysviews"), sql.get(1));
}
private static Connection preparedConnection(List<String> sql, ResultSet... resultSets) {
int[] resultIndex = {0};
PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> {
if ("executeQuery".equals(method.getName())) {
return resultSet;
int index = Math.min(resultIndex[0], resultSets.length - 1);
resultIndex[0] += 1;
return resultSets[index];
}
if ("setString".equals(method.getName()) || "close".equals(method.getName())) {
return null;
@ -158,6 +301,7 @@ class Gbase8sAgentTest {
private static ResultSet resultSet(String[] columns, Object[][] rows) {
int[] index = {-1};
Object[] lastValue = {null};
return proxy(ResultSet.class, (method, args) -> {
switch (method.getName()) {
case "next":
@ -165,7 +309,20 @@ class Gbase8sAgentTest {
return index[0] < rows.length;
case "getString":
Object value = columnValue(columns, rows[index[0]], args[0]);
lastValue[0] = value;
return value == null ? null : String.valueOf(value);
case "getInt":
Object intValue = columnValue(columns, rows[index[0]], args[0]);
lastValue[0] = intValue;
if (intValue == null) {
return 0;
}
if (intValue instanceof Number) {
return ((Number) intValue).intValue();
}
return Integer.parseInt(String.valueOf(intValue));
case "wasNull":
return lastValue[0] == null;
case "close":
return null;
default:

View File

@ -38,7 +38,7 @@ import { connectionRedactedNameLabel } from "@/lib/connection/connectionPresenta
import { quickConnectionOpenTarget } from "@/lib/connection/connectionOpenTarget";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/sql/newQueryContext";
import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/table/objectSourceEditor";
import { buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { uuid } from "@/lib/common/utils";
import { isMacOS } from "@/lib/backend/platform";
@ -762,20 +762,15 @@ async function saveActiveObjectSource(tab: QueryTab): Promise<boolean> {
if (!connection || !source) return false;
try {
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection.db_type;
const statements = await buildExecutableObjectSourceStatements({
databaseType: connection.db_type,
databaseType,
objectType: source.objectType,
schema: source.schema || tab.schema || tab.database,
name: source.name,
source: tab.sql,
});
for (const sql of statements) {
if (objectSourceSaveExecutionMode(connection.db_type) === "single") {
await api.executeQuery(tab.connectionId, tab.database, sql, source.schema || tab.schema);
} else {
await api.executeScript(tab.connectionId, tab.database, sql, source.schema || tab.schema);
}
}
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
queryStore.markTabClean(tab);
toast(t("objects.sourceSaved"), 2000);
return true;

View File

@ -77,6 +77,7 @@ const props = defineProps<{
executionErrorSql?: string;
readOnly?: boolean;
forceWordWrap?: boolean;
hideExecutionControls?: boolean;
initialViewport?: { scrollTop: number; scrollLeft: number };
initialSelection?: { anchor: number; head: number };
}>();
@ -794,13 +795,17 @@ function selectSqlLineFromGutter(currentView: EditorViewType, line: { from: numb
const contextMenuItems = computed<ContextMenuItem[]>(() => {
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
return [
{
label: executeContextMenuLabel.value,
action: executeFromContextMenu,
disabled: !canExecuteContextSql.value,
icon: Play,
shortcut: shortcuts.executeSql,
},
...(props.hideExecutionControls
? []
: [
{
label: executeContextMenuLabel.value,
action: executeFromContextMenu,
disabled: !canExecuteContextSql.value,
icon: Play,
shortcut: shortcuts.executeSql,
},
]),
{
label: t("contextMenu.viewData"),
action: openTableFromContextMenu,
@ -849,6 +854,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
const Prec = codeMirrorPrec;
const binding = (shortcut: string, run: (view: EditorViewType) => boolean) => (shortcut ? [{ key: shortcutToCodeMirrorKey(shortcut), preventDefault: true, run }] : []);
const executeBindings = props.hideExecutionControls ? [] : binding(shortcuts.executeSql, () => requestExecute({ forceCurrent: true }));
return [
Prec?.high(
codeMirrorKeymap.of([
@ -859,7 +865,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
},
...binding(shortcuts.find, openSearch),
...binding(shortcuts.replace, openReplace),
...binding(shortcuts.executeSql, () => requestExecute({ forceCurrent: true })),
...executeBindings,
...binding(shortcuts.saveSql, () => {
emit("save");
return true;
@ -2643,7 +2649,7 @@ onMounted(async () => {
return { dom };
},
}),
runGutterComp.of(buildRunStatementGutterExtension()),
runGutterComp.of(props.hideExecutionControls ? [] : buildRunStatementGutterExtension()),
lineNumbers({
domEventHandlers: {
mousedown: selectSqlLineFromGutter,
@ -3042,7 +3048,7 @@ watch(
codeMirrorTheme.reconfigure(themeExt),
wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []),
vimModeComp.reconfigure(vimModeExtension(settingsStore.editorSettings.vimModeEnabled)),
runGutterComp.reconfigure(buildRunStatementGutterExtension?.() ?? []),
runGutterComp.reconfigure(props.hideExecutionControls ? [] : (buildRunStatementGutterExtension?.() ?? [])),
runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)),
],
});

View File

@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";
import type { EditorView } from "@codemirror/view";
import type { ObjectSourceKind } from "@/types/database";
const props = withDefaults(
defineProps<{
@ -22,6 +23,7 @@ const props = withDefaults(
database: string;
schema?: string;
tableName: string;
objectType?: ObjectSourceKind;
/** SQL dialect for syntax highlighting. Non-PG/non-MSSQL databases fall back to MySQL (same as QueryEditor's source viewer). */
dialect: "mysql" | "postgres" | "sqlserver";
/** SQL formatter dialect. Kept separate from the syntax-highlighting dialect because several PG-compatible DBs highlight as MySQL. */
@ -56,7 +58,7 @@ watch(
ddlLoading.value = true;
try {
const schema = props.schema || props.database;
const ddl = await api.getTableDdl(props.connectionId, props.database, schema, props.tableName);
const ddl = await api.getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType);
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
} catch (e: any) {
ddlError.value = e?.message || String(e);
@ -171,7 +173,7 @@ function retry() {
ddlContent.value = "";
const schema = props.schema || props.database;
api
.getTableDdl(props.connectionId, props.database, schema, props.tableName)
.getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType)
.then(async (ddl) => {
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
})

View File

@ -54,9 +54,8 @@ import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDa
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import { buildDropObjectSql, buildDropTableSql, buildDuplicateTableStructureSql, buildCopyTableDataSql, buildEmptyTableSql, buildTruncateTableSql, supportsDropTableCascade, type TableAdminSqlOptions } from "@/lib/database/dbAdminSql";
import { useToast } from "@/composables/useToast";
import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, objectSourceSaveExecutionMode, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, executeObjectSourceSave, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRenameSql";
import { buildViewDdl } from "@/lib/table/viewDdl";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
@ -123,6 +122,7 @@ const sourceContent = ref("");
const sourceError = ref("");
const sourceRow = ref<ObjectBrowserRow | null>(null);
const sourceEditing = ref(false);
const sourceCanEdit = ref(true);
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
const tableStructureDatabaseType = computed(() => tableStructureDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
const sourceEditableText = ref("");
@ -426,12 +426,14 @@ async function openSource(row: ObjectBrowserRow) {
sourceContent.value = "";
sourceError.value = "";
sourceEditing.value = false;
sourceCanEdit.value = true;
sourceEditableText.value = "";
sourceDraft.value = "";
sourceSaveError.value = "";
sourceLoading.value = true;
try {
const result = await api.getObjectSource(props.connection.id, props.database, row.schema || selectedSchema.value || props.database, row.name, row.type as ObjectSourceKind);
sourceCanEdit.value = result.editable !== false && row.type !== "SEQUENCE";
const editable = await api.buildEditableObjectSource({
databaseType: effectiveDatabaseType.value,
objectType: row.type as ObjectSourceKind,
@ -442,7 +444,10 @@ async function openSource(row: ObjectBrowserRow) {
sourceEditableText.value = editable;
sourceContent.value = await formatSqlForDisplay(editable, sourceFormatDialect.value, settingsStore.editorSettings.sqlFormatter);
sourceDraft.value = editable;
sourceEditing.value = row.type !== "SEQUENCE";
sourceEditing.value = sourceCanEdit.value;
if (!sourceCanEdit.value && row.type !== "SEQUENCE") {
toast(t("objects.sourceReadOnly"), 3000);
}
} catch (e: any) {
sourceError.value = e?.message || String(e);
} finally {
@ -450,27 +455,6 @@ async function openSource(row: ObjectBrowserRow) {
}
}
async function openViewDdl(row: ObjectBrowserRow) {
if (row.type !== "VIEW" && row.type !== "MATERIALIZED_VIEW") return;
try {
const schema = row.schema || selectedSchema.value || props.database;
const ddl =
row.type === "MATERIALIZED_VIEW"
? await api.getTableDdl(props.connection.id, props.database, schema, row.name, "MATERIALIZED_VIEW")
: await buildViewDdl({
databaseType: effectiveDatabaseType.value,
schema,
name: row.name,
source: (await api.getObjectSource(props.connection.id, props.database, schema, row.name, "VIEW")).source,
});
const formatted = await formatSqlForDisplay(ddl, sourceFormatDialect.value, settingsStore.editorSettings.sqlFormatter);
const tabId = queryStore.createTab(props.connection.id, props.database, `DDL - ${row.name}`);
queryStore.updateSql(tabId, formatted);
} catch (e: any) {
toast(e?.message || String(e), 5000);
}
}
async function openNewQuery(row: ObjectBrowserRow) {
const tabId = queryStore.createTab(props.connection.id, props.database, row.name);
queryStore.updateSql(
@ -1219,6 +1203,10 @@ async function copySource() {
function editSource() {
if (!sourceRow.value || !sourceEditableText.value) return;
if (!sourceCanEdit.value) {
toast(t("objects.sourceReadOnly"), 3000);
return;
}
sourceDraft.value = sourceEditableText.value;
sourceSaveError.value = "";
sourceEditing.value = true;
@ -1231,6 +1219,10 @@ function cancelEditSource() {
}
async function saveSource() {
if (!sourceCanEdit.value) {
toast(t("objects.sourceReadOnly"), 3000);
return;
}
if (!sourceRow.value || !sourceDraft.value.trim()) return;
const row = sourceRow.value;
const schema = row.schema || selectedSchema.value || props.database;
@ -1244,13 +1236,7 @@ async function saveSource() {
name: row.name,
source: sourceDraft.value,
});
for (const sql of statements) {
if (objectSourceSaveExecutionMode(effectiveDatabaseType.value) === "single") {
await api.executeQuery(props.connection.id, props.database, sql, schema);
} else {
await api.executeScript(props.connection.id, props.database, sql, schema);
}
}
await executeObjectSourceSave(props.connection.id, props.database, effectiveDatabaseType.value, statements, schema);
toast(t("objects.sourceSaved"));
sourceEditing.value = false;
sourceDraft.value = "";
@ -1502,7 +1488,14 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
{ label: t("contextMenu.editView"), action: () => openSource(item), icon: PencilLine },
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
{ label: t("contextMenu.viewDdl"), action: () => openViewDdl(item), icon: ScrollText },
{
label: t("contextMenu.viewDdl"),
action: () => {
ddlDialogTarget.value = item;
showDdlDialog.value = true;
},
icon: ScrollText,
},
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
@ -1805,7 +1798,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Button v-if="!sourceEditing" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="copySource">
<Copy class="h-3 w-3" />
</Button>
<Button v-if="!sourceEditing" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
<Button v-if="!sourceEditing && sourceCanEdit" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
<PencilLine class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeSource">
@ -1949,7 +1942,17 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</DialogContent>
</Dialog>
<DdlViewDialog v-if="ddlDialogTarget" :connection-id="props.connection.id" :database="props.database" :schema="ddlDialogTarget.schema || selectedSchema" :table-name="ddlDialogTarget.name" :dialect="sourceDialect" :format-dialect="sourceFormatDialect" v-model:open="showDdlDialog" />
<DdlViewDialog
v-if="ddlDialogTarget"
:connection-id="props.connection.id"
:database="props.database"
:schema="ddlDialogTarget.schema || selectedSchema"
:table-name="ddlDialogTarget.name"
:object-type="tableDdlObjectType(ddlDialogTarget.type)"
:dialect="sourceDialect"
:format-dialect="sourceFormatDialect"
v-model:open="showDdlDialog"
/>
</template>
<style scoped>

View File

@ -0,0 +1,247 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Clipboard, Loader2, PencilLine, RefreshCw } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { useSettingsStore } from "@/stores/settingsStore";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
import * as api from "@/lib/backend/api";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
const props = withDefaults(
defineProps<{
open: boolean;
connectionId: string;
database: string;
schema?: string;
name: string;
objectType: ObjectSourceKind;
databaseType?: DatabaseType;
dialect: "mysql" | "postgres" | "sqlserver";
formatDialect?: SqlFormatDialect;
initialEditing?: boolean;
}>(),
{
initialEditing: false,
},
);
const emit = defineEmits<{
"update:open": [value: boolean];
saved: [];
}>();
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const content = ref("");
const editableText = ref("");
const draft = ref("");
const loading = ref(false);
const saving = ref(false);
const editing = ref(false);
const sourceEditable = ref(true);
const error = ref("");
const saveError = ref("");
let loadSerial = 0;
const canEdit = computed(() => sourceEditable.value && props.objectType !== "SEQUENCE");
const title = computed(() => `${editing.value ? t("contextMenu.editView") : t("contextMenu.viewSource")} - ${props.name}`);
watch(
() => [props.open, props.connectionId, props.database, props.schema, props.name, props.objectType, props.initialEditing] as const,
() => {
if (props.open) void loadSource();
},
{ immediate: true },
);
async function loadSource(nextEditing = props.initialEditing && canEdit.value) {
const serial = ++loadSerial;
content.value = "";
editableText.value = "";
draft.value = "";
error.value = "";
saveError.value = "";
sourceEditable.value = true;
editing.value = false;
loading.value = true;
try {
if (!props.databaseType) throw new Error("Connection type is unavailable.");
const schema = props.schema || props.database;
const result = await api.getObjectSource(props.connectionId, props.database, schema, props.name, props.objectType);
const editableAllowed = result.editable !== false;
const editable = await buildEditableObjectSource({
databaseType: props.databaseType,
objectType: props.objectType,
schema,
name: props.name,
source: result.source,
});
if (serial !== loadSerial) return;
sourceEditable.value = editableAllowed;
const formatted = await formatSqlForDisplay(editable, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
editableText.value = editable;
content.value = formatted;
draft.value = nextEditing && canEdit.value ? editable : "";
editing.value = nextEditing && canEdit.value;
if (nextEditing && !canEdit.value) {
toast(t("objects.sourceReadOnly"), 3000);
}
} catch (e: any) {
if (serial === loadSerial) error.value = e?.message || String(e);
} finally {
if (serial === loadSerial) loading.value = false;
}
}
async function copySource() {
if (!content.value) return;
try {
await copyToClipboard(content.value);
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
function editSource() {
if (!canEdit.value || !editableText.value) {
if (!canEdit.value) toast(t("objects.sourceReadOnly"), 3000);
return;
}
draft.value = editableText.value;
saveError.value = "";
editing.value = true;
}
function cancelEditSource() {
editing.value = false;
draft.value = "";
saveError.value = "";
}
async function saveSource() {
if (!canEdit.value) {
toast(t("objects.sourceReadOnly"), 3000);
return;
}
if (!draft.value.trim() || !props.databaseType) return;
const schema = props.schema || props.database;
saving.value = true;
saveError.value = "";
try {
const statements = await buildExecutableObjectSourceStatements({
databaseType: props.databaseType,
objectType: props.objectType,
schema,
name: props.name,
source: draft.value,
});
await executeObjectSourceSave(props.connectionId, props.database, props.databaseType, statements, schema);
toast(t("objects.sourceSaved"));
emit("saved");
await loadSource(false);
} catch (e: any) {
saveError.value = e?.message || String(e);
} finally {
saving.value = false;
}
}
function closeDialog() {
emit("update:open", false);
}
</script>
<template>
<Dialog :open="props.open" @update:open="(value) => emit('update:open', value)">
<DialogContent class="h-[min(760px,calc(100dvh-2rem))] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-[900px]">
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
</DialogHeader>
<div v-if="loading" class="flex min-h-0 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>{{ t("common.loading") }}</span>
</div>
<div v-else-if="error" class="flex min-h-0 flex-col items-center justify-center gap-3 text-sm">
<p class="text-destructive">{{ error }}</p>
<Button variant="outline" size="sm" @click="loadSource()">
<RefreshCw class="h-4 w-4" />
{{ t("common.retry") }}
</Button>
</div>
<div v-else-if="editing" class="object-source-dialog-editor flex min-h-0 flex-col overflow-hidden rounded border" data-object-source-editor>
<QueryEditor
v-model="draft"
class="min-h-0 flex-1"
:connection-id="props.connectionId"
:database="props.database"
:schema="props.schema || props.database"
:database-type="props.databaseType"
:dialect="props.dialect"
:format-dialect="props.formatDialect"
force-word-wrap
hide-execution-controls
@save="saveSource"
/>
<div v-if="saveError" class="shrink-0 border-t px-3 py-2 text-xs text-destructive">
{{ saveError }}
</div>
</div>
<QueryEditor
v-else
:key="`${props.connectionId}:${props.database}:${props.schema || ''}:${props.name}:${props.objectType}`"
:model-value="content"
class="object-source-dialog-editor min-h-0 overflow-hidden rounded border"
:connection-id="props.connectionId"
:database="props.database"
:schema="props.schema || props.database"
:database-type="props.databaseType"
:dialect="props.dialect"
:format-dialect="props.formatDialect"
force-word-wrap
read-only
hide-execution-controls
data-object-source-preview
/>
<DialogFooter>
<Button variant="outline" @click="closeDialog">{{ t("common.close") }}</Button>
<Button v-if="!editing" variant="outline" :disabled="!content" @click="copySource">
<Clipboard class="h-4 w-4" />
{{ t("grid.copy") }}
</Button>
<Button v-if="!editing && canEdit" variant="outline" :disabled="!editableText" @click="editSource">
<PencilLine class="h-4 w-4" />
{{ t("contextMenu.editView") }}
</Button>
<Button v-if="editing" variant="outline" :disabled="saving" @click="cancelEditSource">
{{ t("objects.cancelEdit") }}
</Button>
<Button v-if="editing" :disabled="saving || !draft.trim()" @click="saveSource">
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
{{ t("objects.saveSource") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style scoped>
.object-source-dialog-editor :deep(.cm-editor),
.object-source-dialog-editor :deep(.cm-scroller) {
height: 100%;
}
.object-source-dialog-editor :deep(.cm-scroller) {
overflow: auto !important;
}
</style>

View File

@ -126,10 +126,11 @@ import {
type TableAdminSqlOptions,
} from "@/lib/database/dbAdminSql";
import { buildRenameObjectSql, supportsObjectRename, type RenameableObjectType } from "@/lib/table/objectRenameSql";
import { buildEditableObjectSource, buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
import { buildViewDdl } from "@/lib/table/viewDdl";
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import DdlViewDialog from "@/components/objects/DdlViewDialog.vue";
import ObjectSourceDialog from "@/components/objects/ObjectSourceDialog.vue";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { hexToRgba } from "@/lib/common/color";
@ -709,7 +710,7 @@ function runRowClickAction(clickDetail: number) {
} else if (isDocumentBrowserTreeNode(node.type)) {
openMongoTreeData(node);
} else if (node.type === "procedure" || node.type === "function" || node.type === "sequence" || node.type === "package" || node.type === "package-body") {
void viewObjectSource();
openObjectSourceDialog(false);
} else if (action === "toggle") {
toggle();
}
@ -1041,7 +1042,7 @@ function onDoubleClick() {
} else if (action === "open-data") {
openData();
} else if (action === "open-source") {
void viewObjectSource();
openObjectSourceDialog(false);
} else if (action === "open-saved-sql") {
openSavedSqlFile();
} else if (action === "toggle" && (props.node.type === "mongo-gridfs" || isDocumentBrowserTreeNode(props.node.type))) {
@ -1747,6 +1748,15 @@ const ddlFormatDialect = computed(() => {
if (!ddlTarget.value?.connectionId) return "generic";
return sqlFormatDialectForDbType(effectiveDatabaseTypeForConnection(connectionStore.getConfig(ddlTarget.value.connectionId)));
});
const objectSourceTarget = ref<{ node: TreeNode; initialEditing: boolean } | null>(null);
const showObjectSourceDialog = ref(false);
const objectSourceType = computed(() => (objectSourceTarget.value ? objectSourceKindForTreeNode(objectSourceTarget.value.node.type) : null));
const objectSourceDatabaseType = computed(() => {
const connectionId = objectSourceTarget.value?.node.connectionId;
return connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId)) : undefined;
});
const objectSourceDialect = computed(() => codeMirrorSqlDialect(objectSourceDatabaseType.value));
const objectSourceFormatDialect = computed(() => sqlFormatDialectForDbType(objectSourceDatabaseType.value));
const showCreateDatabaseDialog = ref(false);
const createDatabaseName = ref("");
const createDatabaseCharset = ref("utf8mb4");
@ -1917,66 +1927,17 @@ async function refreshDropTableChildObjectPreviewSql() {
dropTableChildObjectPreviewSql.value = options ? await buildDropTableChildObjectSql(options).catch(() => "") : "";
}
function viewObjectSource() {
function openObjectSourceDialog(initialEditing: boolean) {
const node = props.node;
if (!node.connectionId || !node.database) return;
const objectType = objectSourceKindForTreeNode(node.type);
if (!objectType) return;
const schema = node.schema || node.database;
connectionStore
void connectionStore
.ensureConnected(node.connectionId)
.then(() => {
connectionStore.activeConnectionId = node.connectionId!;
return api.getObjectSource(node.connectionId!, node.database!, schema, node.label, objectType as any);
})
.then(async (result) => {
const databaseType = currentDatabaseType();
if (!databaseType) throw new Error("Connection type is unavailable.");
const tabId = queryStore.createTab(node.connectionId!, node.database!, `Source - ${node.label}`);
const editable = await buildEditableObjectSource({
databaseType,
objectType,
schema,
name: node.label,
source: result.source,
});
queryStore.updateSql(tabId, editable);
if (objectType !== "SEQUENCE") {
queryStore.setObjectSource(tabId, {
schema,
name: node.label,
objectType,
});
}
queryStore.markTabClean(queryStore.tabs.find((tab) => tab.id === tabId));
})
.catch((e: any) => {
toast(e?.message || String(e), 5000);
});
}
function viewObjectDdl() {
const node = props.node;
if ((node.type !== "view" && node.type !== "materialized_view") || !node.connectionId || !node.database) return;
const schema = node.schema || node.database;
const objectType = node.type === "materialized_view" ? "MATERIALIZED_VIEW" : "VIEW";
connectionStore
.ensureConnected(node.connectionId)
.then(() => {
connectionStore.activeConnectionId = node.connectionId!;
return api.getObjectSource(node.connectionId!, node.database!, schema, node.label, objectType);
})
.then(async (result) => {
const connection = connectionStore.getConfig(node.connectionId!);
const ddl = await buildViewDdl({
databaseType: effectiveDatabaseTypeForConnection(connection),
schema,
name: node.label,
source: result.source,
});
const formatted = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(effectiveDatabaseTypeForConnection(connection)), settingsStore.editorSettings.sqlFormatter);
const tabId = queryStore.createTab(node.connectionId!, node.database!, `DDL - ${node.label}`);
queryStore.updateSql(tabId, formatted);
objectSourceTarget.value = { node, initialEditing };
showObjectSourceDialog.value = true;
})
.catch((e: any) => {
toast(e?.message || String(e), 5000);
@ -3273,9 +3234,16 @@ function createView() {
const node = props.node;
if (!node.connectionId || !node.database) return;
connectionStore.activeConnectionId = node.connectionId;
const viewName = node.schema ? `${node.schema}.new_view` : "new_view";
const viewName = "new_view";
const effectiveDbType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId));
const viewSqlName = effectiveDbType === "informix" || !node.schema ? viewName : `${node.schema}.${viewName}`;
const tabId = queryStore.createTab(node.connectionId, node.database, t("contextMenu.createView"), "query", node.schema);
queryStore.updateSql(tabId, `CREATE VIEW ${viewName} AS\nSELECT\n *\nFROM table_name;\n`);
queryStore.updateSql(tabId, `CREATE VIEW ${viewSqlName} AS\nSELECT\n *\nFROM table_name;\n`);
queryStore.setObjectSource(tabId, {
schema: node.schema,
name: viewName,
objectType: "VIEW",
});
}
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
@ -4697,9 +4665,16 @@ function treeItemMenuItems(): ContextMenuItem[] {
});
}
if (node.type === "view" || node.type === "materialized_view") {
items.push({ label: t("contextMenu.editView"), action: viewObjectSource, icon: Pencil });
items.push({ label: t("contextMenu.viewSource"), action: viewObjectSource, icon: Code2 });
items.push({ label: t("contextMenu.viewDdl"), action: viewObjectDdl, icon: FileCode });
items.push({ label: t("contextMenu.editView"), action: () => openObjectSourceDialog(true), icon: Pencil });
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({
label: t("contextMenu.viewDdl"),
action: () => {
ddlTarget.value = node;
showDdlDialog.value = true;
},
icon: FileCode,
});
}
if (canOpenStructureEditor.value) {
items.push({ label: t("contextMenu.editStructure"), action: openStructureEditor, icon: PencilRuler });
@ -4853,7 +4828,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
if (node.type === "procedure") {
items.push({ label: t("contextMenu.executeProcedure"), action: openProcedureExecution, icon: Play });
}
items.push({ label: t("contextMenu.viewSource"), action: viewObjectSource, icon: Code2 });
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
if (canRenameObject.value) {
items.push({
label: t("contextMenu.renameObject"),
@ -4874,14 +4849,14 @@ function treeItemMenuItems(): ContextMenuItem[] {
}
if (node.type === "sequence") {
items.push({ label: t("contextMenu.viewSource"), action: viewObjectSource, icon: Code2 });
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
return items;
}
if (node.type === "package" || node.type === "package-body") {
items.push({ label: t("contextMenu.viewSource"), action: viewObjectSource, icon: Code2 });
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
return items;
@ -5561,7 +5536,32 @@ function treeItemMenuItems(): ContextMenuItem[] {
<DangerConfirmDialog v-model:open="showDropSchemaConfirm" :title="t('contextMenu.confirmDropSchemaTitle')" :message="t('contextMenu.confirmDropSchemaMessage', { name: node.label })" :sql="dropSchemaPreviewSql" :confirm-label="t('contextMenu.dropSchema')" @confirm="confirmDropSchema" />
<DdlViewDialog v-if="ddlTarget" :connection-id="ddlTarget.connectionId!" :database="ddlTarget.database!" :schema="ddlTarget.schema" :table-name="ddlTarget.label" :dialect="ddlDialect" :format-dialect="ddlFormatDialect" v-model:open="showDdlDialog" />
<DdlViewDialog
v-if="ddlTarget"
:connection-id="ddlTarget.connectionId!"
:database="ddlTarget.database!"
:schema="ddlTarget.schema"
:table-name="ddlTarget.label"
:object-type="tableDdlObjectTypeForNode(ddlTarget.type)"
:dialect="ddlDialect"
:format-dialect="ddlFormatDialect"
v-model:open="showDdlDialog"
/>
<ObjectSourceDialog
v-if="objectSourceTarget && objectSourceType"
v-model:open="showObjectSourceDialog"
:connection-id="objectSourceTarget.node.connectionId!"
:database="objectSourceTarget.node.database!"
:schema="objectSourceTarget.node.schema"
:name="objectSourceTarget.node.label"
:object-type="objectSourceType"
:database-type="objectSourceDatabaseType"
:dialect="objectSourceDialect"
:format-dialect="objectSourceFormatDialect"
:initial-editing="objectSourceTarget.initialEditing"
@saved="refresh"
/>
<InstallExtensionDialog ref="installExtensionDialogRef" :node="node" @close="refresh" />
</template>

View File

@ -1668,6 +1668,7 @@ export default {
cancelEdit: "Cancel",
sourceSaved: "Source saved",
sourceSaveFailed: "Failed to save source: {message}",
sourceReadOnly: "This source is read-only and cannot be edited.",
schemaColumn: "Schema",
comment: "Comment",
loadingSchemas: "Loading schemas...",

View File

@ -1628,6 +1628,7 @@ export default withEnglishFallback({
batchDropSuccess: "{count} tablas eliminadas",
copyTableSelected: "Copiar",
pasteTableSelected: "Pegar",
sourceReadOnly: "Este código fuente es de solo lectura y no se puede editar.",
},
structureEditor: {
title: "Editar estructura de tabla",

View File

@ -1626,6 +1626,7 @@ export default withEnglishFallback({
batchDropSuccess: "Eliminate {count} tabelle",
copyTableSelected: "Copia",
pasteTableSelected: "Incolla",
sourceReadOnly: "Il codice sorgente è di sola lettura, non modificabile.",
},
structureEditor: {
title: "Modifica Struttura Tabella",

View File

@ -1659,6 +1659,7 @@ export default withEnglishFallback({
batchDropSuccess: "{count}テーブルを削除しました",
copyTableSelected: "コピー",
pasteTableSelected: "貼り付け",
sourceReadOnly: "このソースは読み取り専用で、編集できません。",
},
structureEditor: {
title: "テーブル構造を編集",

View File

@ -1627,6 +1627,7 @@ export default withEnglishFallback({
batchDropSuccess: "{count} tabelas removidas",
copyTableSelected: "Copiar",
pasteTableSelected: "Colar",
sourceReadOnly: "O código fonte é somente leitura e não pode ser editado.",
},
structureEditor: {
title: "Editar estrutura da tabela",

View File

@ -1668,6 +1668,7 @@ export default withEnglishFallback({
cancelEdit: "取消",
sourceSaved: "源码已保存",
sourceSaveFailed: "保存源码失败:{message}",
sourceReadOnly: "该源码为只读,不能编辑。",
schemaColumn: "Schema",
comment: "注释",
loadingSchemas: "加载 Schema...",

View File

@ -1566,6 +1566,7 @@ export default withEnglishFallback({
cancelEdit: "取消",
sourceSaved: "原始碼已儲存",
sourceSaveFailed: "儲存原始碼失敗:{message}",
sourceReadOnly: "此原始碼為唯讀,不能編輯。",
schemaColumn: "Schema",
comment: "註解",
loadingSchemas: "載入 Schema……",

View File

@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "@/lib/backend/api";
import { executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
vi.mock("@/lib/backend/api", () => ({
executeInTransaction: vi.fn().mockResolvedValue({}),
executeQuery: vi.fn().mockResolvedValue({}),
executeScript: vi.fn().mockResolvedValue({}),
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe("executeObjectSourceSave", () => {
it("runs multi-statement Informix source saves in a transaction", async () => {
await executeObjectSourceSave("conn-1", "stores", "informix", ["CREATE TEMP VIEW v AS SELECT 1", " ", "DROP VIEW v", "CREATE VIEW v AS SELECT 2"], "app");
expect(api.executeInTransaction).toHaveBeenCalledOnce();
expect(api.executeInTransaction).toHaveBeenCalledWith("conn-1", "stores", ["CREATE TEMP VIEW v AS SELECT 1", "DROP VIEW v", "CREATE VIEW v AS SELECT 2"], "app");
expect(api.executeQuery).not.toHaveBeenCalled();
expect(api.executeScript).not.toHaveBeenCalled();
});
it("keeps non-Informix source saves on the existing per-statement path", async () => {
await executeObjectSourceSave("conn-1", "app", "mysql", ["ALTER VIEW v AS SELECT 1", "", "ALTER VIEW v AS SELECT 2"], "public");
expect(api.executeInTransaction).not.toHaveBeenCalled();
expect(api.executeQuery).toHaveBeenCalledTimes(2);
expect(api.executeQuery).toHaveBeenNthCalledWith(1, "conn-1", "app", "ALTER VIEW v AS SELECT 1", "public");
expect(api.executeQuery).toHaveBeenNthCalledWith(2, "conn-1", "app", "ALTER VIEW v AS SELECT 2", "public");
expect(api.executeScript).not.toHaveBeenCalled();
});
});

View File

@ -44,3 +44,23 @@ export function buildEditableObjectSource(input: BuildEditableObjectSourceSqlInp
export function objectSourceSaveExecutionMode(_databaseType: DatabaseType): ObjectSourceSaveExecutionMode {
return "single";
}
export async function executeObjectSourceSave(connectionId: string, database: string, databaseType: DatabaseType, statements: string[], schema?: string): Promise<void> {
const nonEmptyStatements = statements.filter((sql) => sql.trim().length > 0);
if (nonEmptyStatements.length === 0) return;
if (databaseType === "informix" && nonEmptyStatements.length > 1) {
// Informix/GBase 8s view replacement is validate + drop/create; run it atomically
// so a failing final CREATE rolls back the original view instead of deleting it.
await api.executeInTransaction(connectionId, database, nonEmptyStatements, schema);
return;
}
for (const sql of nonEmptyStatements) {
if (objectSourceSaveExecutionMode(databaseType) === "single") {
await api.executeQuery(connectionId, database, sql, schema);
} else {
await api.executeScript(connectionId, database, sql, schema);
}
}
}

View File

@ -332,6 +332,7 @@ export interface ObjectSource {
object_type: ObjectSourceKind;
schema?: string | null;
source: string;
editable?: boolean;
}
export interface ColumnInfo {

View File

@ -272,7 +272,7 @@ pub async fn object_source(
)
.await?,
)?;
Ok(ObjectSource { name: name.to_string(), object_type: object_type.clone(), schema: None, source })
Ok(ObjectSource { name: name.to_string(), object_type: object_type.clone(), schema: None, source, editable: None })
}
pub async fn execute_query(client: &RqliteClient, sql: &str) -> Result<QueryResult, String> {

View File

@ -311,7 +311,7 @@ pub async fn object_source(
)
.await?,
)?;
Ok(ObjectSource { name: name.to_string(), object_type: object_type.clone(), schema: None, source })
Ok(ObjectSource { name: name.to_string(), object_type: object_type.clone(), schema: None, source, editable: None })
}
pub async fn execute_query(client: &TursoClient, sql: &str) -> Result<QueryResult, String> {

View File

@ -152,6 +152,14 @@ pub fn build_executable_object_source_statements(input: EditableObjectSourceSqlI
)]);
}
if is_oracle_like(input.database_type) && input.object_type == ObjectSourceKind::View {
return Ok(vec![executable_oracle_view_ddl(input.schema.as_deref(), &input.name, source)]);
}
if input.database_type == DatabaseType::Informix && input.object_type == ObjectSourceKind::View {
return Ok(executable_informix_view_statements(input.schema.as_deref(), &input.name, source));
}
let create_statement = ensure_semicolon(source);
let cleanup = build_routine_rename_cleanup(&input, source);
Ok(if let Some(cleanup) = cleanup { vec![create_statement, cleanup] } else { vec![create_statement] })
@ -185,6 +193,9 @@ pub fn build_editable_object_source(input: EditableObjectSourceSqlInput) -> Stri
// Some providers return full view DDL instead of a bare SELECT body.
return ensure_semicolon(source.trim());
}
if input.database_type == DatabaseType::Informix && input.object_type == ObjectSourceKind::View {
return editable_informix_view_ddl(input.schema.as_deref(), &input.name, &source);
}
match build_executable_object_source_statements(input) {
Ok(statements) => statements.into_iter().next().unwrap_or_default(),
Err(_) => ensure_semicolon(source.trim()),
@ -329,6 +340,68 @@ fn executable_postgres_view_ddl(source: &str) -> Option<String> {
None
}
fn executable_oracle_view_ddl(schema: Option<&str>, name: &str, source: &str) -> String {
let trimmed = source.trim();
if Regex::new(r"(?i)^CREATE\s+OR\s+REPLACE\s+").unwrap().is_match(trimmed) || source_starts_with_alter(trimmed) {
return ensure_semicolon(trimmed);
}
let create_view = Regex::new(r"(?i)^CREATE\s+((?:(?:NO)?FORCE\s+)?(?:(?:NON)?EDITIONABLE\s+)?VIEW\s+)").unwrap();
if create_view.is_match(trimmed) {
let replaced = create_view.replace(trimmed, "CREATE OR REPLACE $1");
return ensure_semicolon(replaced.as_ref());
}
format!("CREATE OR REPLACE VIEW {} AS\n{}", postgres_qualified_name(schema, name), ensure_semicolon(trimmed))
}
fn executable_informix_view_statements(schema: Option<&str>, name: &str, source: &str) -> Vec<String> {
let (target_name, create_tail) = informix_view_definition(schema, name, source);
if source_starts_with_alter(source.trim()) {
return vec![ensure_semicolon(source.trim())];
}
let validation_name = informix_validation_view_name(&target_name);
let mut statements = vec![
drop_informix_view_if_exists(&validation_name),
create_informix_view(&validation_name, &create_tail),
drop_informix_view_if_exists(&validation_name),
];
let original_name = informix_identifier(name);
if !target_name.eq_ignore_ascii_case(&original_name) {
statements.push(drop_informix_view_if_exists(&original_name));
}
statements.push(drop_informix_view_if_exists(&target_name));
statements.push(create_informix_view(&target_name, &create_tail));
statements
}
fn editable_informix_view_ddl(schema: Option<&str>, name: &str, source: &str) -> String {
if source_starts_with_alter(source.trim()) {
return ensure_semicolon(source.trim());
}
let (target_name, create_tail) = informix_view_definition(schema, name, source);
create_informix_view(&target_name, &create_tail)
}
fn informix_view_definition(schema: Option<&str>, name: &str, source: &str) -> (String, String) {
let trimmed = source.trim();
let create_view = Regex::new(
r#"(?is)^\s*CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+((?:"(?:""|[^"])+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:""|[^"])+"|[A-Za-z_][\w$]*))?)"#,
)
.unwrap();
if let Some(captures) = create_view.captures(trimmed) {
let view_name = captures.get(1).unwrap();
let target_name = strip_informix_owner_qualifiers(view_name.as_str(), schema);
let body = strip_informix_owner_qualifiers(&trimmed[view_name.end()..], schema);
return (target_name.trim().to_string(), body);
} else {
let body = strip_informix_owner_qualifiers(trimmed, schema);
(informix_identifier(name), format!(" AS\n{body}"))
}
}
fn postgres_qualified_name(schema: Option<&str>, name: &str) -> String {
schema
.into_iter()
@ -339,6 +412,201 @@ fn postgres_qualified_name(schema: Option<&str>, name: &str) -> String {
.join(".")
}
fn informix_identifier(name: &str) -> String {
if is_simple_informix_identifier(name) {
name.to_string()
} else {
quote_postgres_identifier(name)
}
}
fn create_informix_view(name: &str, create_tail: &str) -> String {
ensure_semicolon(&format!("CREATE VIEW {}{}", name.trim(), create_tail))
}
fn drop_informix_view_if_exists(name: &str) -> String {
format!("DROP VIEW IF EXISTS {};", name.trim())
}
fn informix_validation_view_name(target_name: &str) -> String {
let mut hash = 0x811c9dc5u32;
for byte in target_name.bytes() {
hash ^= byte as u32;
hash = hash.wrapping_mul(0x01000193);
}
format!("dbx_view_check_{hash:08x}")
}
fn strip_informix_owner_qualifiers(source: &str, schema: Option<&str>) -> String {
let Some(schema) = schema.map(str::trim).filter(|schema| !schema.is_empty()) else {
return source.to_string();
};
let mut result = String::with_capacity(source.len());
let mut index = 0;
while index < source.len() {
if let Some(end) = sql_single_quoted_literal_end(source, index) {
result.push_str(&source[index..end]);
index = end;
continue;
}
if let Some(end) = sql_line_comment_end(source, index) {
result.push_str(&source[index..end]);
index = end;
continue;
}
if let Some(end) = sql_block_comment_end(source, index) {
result.push_str(&source[index..end]);
index = end;
continue;
}
if let Some((end, replacement)) = informix_owner_qualifier_replacement(source, index, schema) {
result.push_str(replacement);
index = end;
continue;
}
let ch = source[index..].chars().next().unwrap();
result.push(ch);
index += ch.len_utf8();
}
result
}
fn informix_owner_qualifier_replacement<'a>(source: &'a str, start: usize, schema: &str) -> Option<(usize, &'a str)> {
if let Some((owner_end, owner)) = read_quoted_sql_identifier(source, start) {
if owner.eq_ignore_ascii_case(schema) {
let dot = skip_sql_whitespace(source, owner_end);
if source[dot..].starts_with('.') {
let ident_start = skip_sql_whitespace(source, dot + 1);
if let Some((ident_end, ident_text)) = read_informix_identifier_text(source, ident_start) {
return Some((ident_end, ident_text));
}
}
}
}
if !is_simple_informix_identifier(schema) || !starts_with_ignore_ascii_case_at(source, start, schema) {
return None;
}
if source[..start].chars().next_back().is_some_and(is_informix_identifier_part) {
return None;
}
let owner_end = start + schema.len();
if source[owner_end..].chars().next().is_some_and(is_informix_identifier_part) {
return None;
}
let dot = skip_sql_whitespace(source, owner_end);
if !source[dot..].starts_with('.') {
return None;
}
let ident_start = skip_sql_whitespace(source, dot + 1);
read_informix_identifier_text(source, ident_start).map(|(ident_end, ident_text)| (ident_end, ident_text))
}
fn sql_single_quoted_literal_end(source: &str, start: usize) -> Option<usize> {
if !source[start..].starts_with('\'') {
return None;
}
let mut index = start + 1;
while index < source.len() {
let ch = source[index..].chars().next().unwrap();
index += ch.len_utf8();
if ch == '\'' {
if source[index..].starts_with('\'') {
index += 1;
} else {
return Some(index);
}
}
}
Some(source.len())
}
fn sql_line_comment_end(source: &str, start: usize) -> Option<usize> {
if !source[start..].starts_with("--") {
return None;
}
let rest = &source[start..];
Some(start + rest.find('\n').map(|index| index + 1).unwrap_or(rest.len()))
}
fn sql_block_comment_end(source: &str, start: usize) -> Option<usize> {
if !source[start..].starts_with("/*") {
return None;
}
let rest = &source[start + 2..];
Some(start + 2 + rest.find("*/").map(|index| index + 2).unwrap_or(rest.len()))
}
fn read_quoted_sql_identifier(source: &str, start: usize) -> Option<(usize, String)> {
if !source[start..].starts_with('"') {
return None;
}
let mut value = String::new();
let mut index = start + 1;
while index < source.len() {
let ch = source[index..].chars().next().unwrap();
index += ch.len_utf8();
if ch == '"' {
if source[index..].starts_with('"') {
value.push('"');
index += 1;
} else {
return Some((index, value));
}
} else {
value.push(ch);
}
}
None
}
fn read_informix_identifier_text(source: &str, start: usize) -> Option<(usize, &str)> {
if let Some((end, _)) = read_quoted_sql_identifier(source, start) {
return Some((end, &source[start..end]));
}
let first = source[start..].chars().next()?;
if !is_informix_identifier_start(first) {
return None;
}
let mut end = start + first.len_utf8();
while end < source.len() {
let ch = source[end..].chars().next().unwrap();
if !is_informix_identifier_part(ch) {
break;
}
end += ch.len_utf8();
}
Some((end, &source[start..end]))
}
fn skip_sql_whitespace(source: &str, mut index: usize) -> usize {
while index < source.len() {
let ch = source[index..].chars().next().unwrap();
if !ch.is_whitespace() {
break;
}
index += ch.len_utf8();
}
index
}
fn starts_with_ignore_ascii_case_at(source: &str, start: usize, needle: &str) -> bool {
source.get(start..start + needle.len()).is_some_and(|value| value.eq_ignore_ascii_case(needle))
}
fn is_informix_identifier_start(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphabetic()
}
fn is_informix_identifier_part(ch: char) -> bool {
ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()
}
fn is_simple_informix_identifier(name: &str) -> bool {
Regex::new(r"^[A-Za-z_][A-Za-z0-9_$]*$").unwrap().is_match(name)
}
fn mysql_qualified_name(schema: Option<&str>, name: &str) -> String {
schema
.into_iter()
@ -504,6 +772,36 @@ mod tests {
}
}
fn informix_view_statements(name: &str, source: &str) -> Vec<String> {
build_executable_object_source_statements(EditableObjectSourceSqlInput {
database_type: DatabaseType::Informix,
object_type: ObjectSourceKind::View,
schema: Some("gbasedbt".to_string()),
name: name.to_string(),
source: source.to_string(),
})
.unwrap()
}
fn expected_informix_view_replace_statements(
original_name: &str,
target_name: &str,
create_tail: &str,
) -> Vec<String> {
let validation_name = informix_validation_view_name(target_name);
let mut statements = vec![
drop_informix_view_if_exists(&validation_name),
create_informix_view(&validation_name, create_tail),
drop_informix_view_if_exists(&validation_name),
];
if !target_name.eq_ignore_ascii_case(original_name) {
statements.push(drop_informix_view_if_exists(original_name));
}
statements.push(drop_informix_view_if_exists(target_name));
statements.push(create_informix_view(target_name, create_tail));
statements
}
#[test]
fn sqlserver_edited_source_saves_as_alter() {
let sql = build_executable_object_source_sql(EditableObjectSourceSqlInput {
@ -693,6 +991,141 @@ mod tests {
}
}
#[test]
fn oracle_view_body_saves_as_create_or_replace_view() {
let sql = build_executable_object_source_sql(EditableObjectSourceSqlInput {
database_type: DatabaseType::Oracle,
object_type: ObjectSourceKind::View,
schema: Some("DBX_TEST".to_string()),
name: "V_ACTIVE_USERS".to_string(),
source: "SELECT id, name FROM users WHERE active = 1".to_string(),
})
.unwrap();
assert_eq!(
sql,
"CREATE OR REPLACE VIEW \"DBX_TEST\".\"V_ACTIVE_USERS\" AS\nSELECT id, name FROM users WHERE active = 1;"
);
}
#[test]
fn oracle_view_create_source_saves_as_create_or_replace_view() {
let sql = build_executable_object_source_sql(EditableObjectSourceSqlInput {
database_type: DatabaseType::Oracle,
object_type: ObjectSourceKind::View,
schema: Some("DBX_TEST".to_string()),
name: "V_ACTIVE_USERS".to_string(),
source: "CREATE FORCE EDITIONABLE VIEW DBX_TEST.V_ACTIVE_USERS AS SELECT id FROM users".to_string(),
})
.unwrap();
assert_eq!(sql, "CREATE OR REPLACE FORCE EDITIONABLE VIEW DBX_TEST.V_ACTIVE_USERS AS SELECT id FROM users;");
}
#[test]
fn oracle_view_source_opened_for_editing_shows_create_or_replace_view() {
let sql = build_editable_object_source(EditableObjectSourceSqlInput {
database_type: DatabaseType::Oracle,
object_type: ObjectSourceKind::View,
schema: Some("DBX_TEST".to_string()),
name: "V_ACTIVE_USERS".to_string(),
source: "SELECT id, name FROM users WHERE active = 1".to_string(),
});
assert_eq!(
sql,
"CREATE OR REPLACE VIEW \"DBX_TEST\".\"V_ACTIVE_USERS\" AS\nSELECT id, name FROM users WHERE active = 1;"
);
}
#[test]
fn informix_view_body_saves_with_validate_drop_create() {
let statements = informix_view_statements("demo_view", "SELECT id, name FROM users");
assert_eq!(
statements,
expected_informix_view_replace_statements("demo_view", "demo_view", " AS\nSELECT id, name FROM users")
);
}
#[test]
fn informix_view_create_source_strips_owner_qualifier_before_save() {
let statements =
informix_view_statements("demo_view", "create view \"gbasedbt\".demo_view (id) as select id from users");
assert_eq!(
statements,
expected_informix_view_replace_statements("demo_view", "demo_view", " (id) as select id from users")
);
}
#[test]
fn informix_view_create_source_preserves_sql_target_name() {
let statements = informix_view_statements("new_view", "create view codex_created_view as select id from users");
assert_eq!(
statements,
expected_informix_view_replace_statements("new_view", "codex_created_view", " as select id from users")
);
}
#[test]
fn informix_view_create_source_strips_same_owner_table_references() {
let statements = informix_view_statements(
"dba_db_links",
"create view \"gbasedbt\".dba_db_links as select x0.db_link from \"gbasedbt\".user_db_links x0",
);
assert_eq!(
statements,
expected_informix_view_replace_statements(
"dba_db_links",
"dba_db_links",
" as select x0.db_link from user_db_links x0",
)
);
}
#[test]
fn informix_view_body_strips_same_owner_table_references() {
let statements = informix_view_statements("demo_view", "SELECT id FROM gbasedbt.users");
assert_eq!(
statements,
expected_informix_view_replace_statements("demo_view", "demo_view", " AS\nSELECT id FROM users")
);
}
#[test]
fn informix_owner_qualifier_rewrite_skips_strings_and_comments() {
let statements = informix_view_statements(
"demo_view",
"SELECT 'gbasedbt.users' AS literal, id FROM gbasedbt.users -- gbasedbt.audit\n/* gbasedbt.logs */",
);
assert_eq!(
statements,
expected_informix_view_replace_statements(
"demo_view",
"demo_view",
" AS\nSELECT 'gbasedbt.users' AS literal, id FROM users -- gbasedbt.audit\n/* gbasedbt.logs */",
)
);
}
#[test]
fn informix_view_source_opened_for_editing_shows_unqualified_create_view() {
let sql = build_editable_object_source(EditableObjectSourceSqlInput {
database_type: DatabaseType::Informix,
object_type: ObjectSourceKind::View,
schema: Some("gbasedbt".to_string()),
name: "demo_view".to_string(),
source: "create view \"gbasedbt\".demo_view as select id from users".to_string(),
});
assert_eq!(sql, "CREATE VIEW demo_view as select id from users;");
}
#[test]
fn view_ddl_wraps_postgres_body_as_create_or_replace_view() {
let sql = build_view_ddl_sql(BuildViewDdlInput {

View File

@ -4772,6 +4772,7 @@ async fn get_object_source_once(
object_type,
schema: if schema.is_empty() { None } else { Some(schema.to_string()) },
source,
editable: None,
})
}

View File

@ -2858,6 +2858,7 @@ async fn get_postgres_schema_object_sources_for_transfer(
object_type: db::ObjectSourceKind::View,
schema: Some(schema.to_string()),
source,
editable: None,
});
}
for row in execute_on_pool(state, pool_key, &routines_sql).await?.rows {
@ -2871,7 +2872,13 @@ async fn get_postgres_schema_object_sources_for_transfer(
let Some(source) = json_string_cell(&row, 2) else {
continue;
};
sources.push(db::ObjectSource { name, object_type: kind, schema: Some(schema.to_string()), source });
sources.push(db::ObjectSource {
name,
object_type: kind,
schema: Some(schema.to_string()),
source,
editable: None,
});
}
Ok(sources)

View File

@ -76,6 +76,8 @@ pub struct ObjectSource {
pub object_type: ObjectSourceKind,
pub schema: Option<String>,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub editable: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]