fix(gbase8s): make view source saves safer
This commit is contained in:
parent
15b86ad6c9
commit
668919a7c9
|
|
@ -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
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1659,6 +1659,7 @@ export default withEnglishFallback({
|
|||
batchDropSuccess: "{count}テーブルを削除しました",
|
||||
copyTableSelected: "コピー",
|
||||
pasteTableSelected: "貼り付け",
|
||||
sourceReadOnly: "このソースは読み取り専用で、編集できません。",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "テーブル構造を編集",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1668,6 +1668,7 @@ export default withEnglishFallback({
|
|||
cancelEdit: "取消",
|
||||
sourceSaved: "源码已保存",
|
||||
sourceSaveFailed: "保存源码失败:{message}",
|
||||
sourceReadOnly: "该源码为只读,不能编辑。",
|
||||
schemaColumn: "Schema",
|
||||
comment: "注释",
|
||||
loadingSchemas: "加载 Schema...",
|
||||
|
|
|
|||
|
|
@ -1566,6 +1566,7 @@ export default withEnglishFallback({
|
|||
cancelEdit: "取消",
|
||||
sourceSaved: "原始碼已儲存",
|
||||
sourceSaveFailed: "儲存原始碼失敗:{message}",
|
||||
sourceReadOnly: "此原始碼為唯讀,不能編輯。",
|
||||
schemaColumn: "Schema",
|
||||
comment: "註解",
|
||||
loadingSchemas: "載入 Schema……",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@ export interface ObjectSource {
|
|||
object_type: ObjectSourceKind;
|
||||
schema?: string | null;
|
||||
source: string;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue