mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 11:36:21 +00:00
chore(oceanbase): pin ROWID and synonym development dependencies
This commit is contained in:
@@ -15,6 +15,9 @@ public final class ColumnInfo {
|
||||
private Integer character_maximum_length;
|
||||
private String character_set;
|
||||
private String collation;
|
||||
private String resolved_schema;
|
||||
private String resolved_table;
|
||||
private String resolved_object_type;
|
||||
|
||||
public ColumnInfo() {
|
||||
this("", "", true, null, false);
|
||||
@@ -128,6 +131,18 @@ public final class ColumnInfo {
|
||||
return collation;
|
||||
}
|
||||
|
||||
public String getResolved_schema() {
|
||||
return resolved_schema;
|
||||
}
|
||||
|
||||
public String getResolved_table() {
|
||||
return resolved_table;
|
||||
}
|
||||
|
||||
public String getResolved_object_type() {
|
||||
return resolved_object_type;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
@@ -176,6 +191,18 @@ public final class ColumnInfo {
|
||||
this.collation = collation;
|
||||
}
|
||||
|
||||
public void setResolved_schema(String resolved_schema) {
|
||||
this.resolved_schema = resolved_schema;
|
||||
}
|
||||
|
||||
public void setResolved_table(String resolved_table) {
|
||||
this.resolved_table = resolved_table;
|
||||
}
|
||||
|
||||
public void setResolved_object_type(String resolved_object_type) {
|
||||
this.resolved_object_type = resolved_object_type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -192,7 +219,10 @@ public final class ColumnInfo {
|
||||
&& Objects.equals(numeric_scale, that.numeric_scale)
|
||||
&& Objects.equals(character_maximum_length, that.character_maximum_length)
|
||||
&& Objects.equals(character_set, that.character_set)
|
||||
&& Objects.equals(collation, that.collation);
|
||||
&& Objects.equals(collation, that.collation)
|
||||
&& Objects.equals(resolved_schema, that.resolved_schema)
|
||||
&& Objects.equals(resolved_table, that.resolved_table)
|
||||
&& Objects.equals(resolved_object_type, that.resolved_object_type);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -209,7 +239,10 @@ public final class ColumnInfo {
|
||||
numeric_scale,
|
||||
character_maximum_length,
|
||||
character_set,
|
||||
collation
|
||||
collation,
|
||||
resolved_schema,
|
||||
resolved_table,
|
||||
resolved_object_type
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,6 +260,9 @@ public final class ColumnInfo {
|
||||
+ ", character_maximum_length=" + character_maximum_length
|
||||
+ ", character_set=" + character_set
|
||||
+ ", collation=" + collation
|
||||
+ ", resolved_schema=" + resolved_schema
|
||||
+ ", resolved_table=" + resolved_table
|
||||
+ ", resolved_object_type=" + resolved_object_type
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ public interface DatabaseAgent {
|
||||
|
||||
List<ColumnInfo> getColumns(String schema, String table);
|
||||
|
||||
default List<ColumnInfo> getColumnsInContext(String schema, String table, String currentSchema) {
|
||||
return getColumns(schema, table);
|
||||
}
|
||||
|
||||
default ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
throw new UnsupportedOperationException("Object source is not supported");
|
||||
}
|
||||
|
||||
@@ -265,7 +265,9 @@ public final class JsonRpcServer {
|
||||
}
|
||||
if (AgentProtocol.METHOD_GET_COLUMNS.equals(method)) {
|
||||
switchCatalog(params);
|
||||
return agent.getColumns(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
String currentSchema = params.has("current_schema") && !params.get("current_schema").isJsonNull()
|
||||
? params.get("current_schema").getAsString() : null;
|
||||
return agent.getColumnsInContext(params.get("schema").getAsString(), params.get("table").getAsString(), currentSchema);
|
||||
}
|
||||
if (AgentProtocol.METHOD_LIST_INDEXES.equals(method)) {
|
||||
switchCatalog(params);
|
||||
|
||||
+136
@@ -907,6 +907,142 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return getColumnsInContext(schema, table, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumnsInContext(String schema, String table, String currentSchema) {
|
||||
return unchecked(() -> {
|
||||
String owner = normalizeSchema(schema == null || schema.isBlank() ? currentSchema : schema);
|
||||
String tableName = normalizeObjectName(table);
|
||||
String initialOwner = owner;
|
||||
String initialName = tableName;
|
||||
boolean publicFallback = false;
|
||||
Set<List<String>> visited = new HashSet<>();
|
||||
for (int depth = 0; depth <= 32; depth++) {
|
||||
if (!visited.add(List.of(owner, tableName))) {
|
||||
throw new SQLException("Synonym cycle detected at " + owner + "." + tableName);
|
||||
}
|
||||
List<ColumnInfo> columns = getObjectColumns(owner, tableName);
|
||||
if (!columns.isEmpty()) {
|
||||
if (depth > 0) {
|
||||
if (publicFallback) verifyPublicColumnTarget(initialOwner, initialName, owner, tableName);
|
||||
String objectType = queryColumnObjectType(owner, tableName);
|
||||
for (ColumnInfo column : columns) {
|
||||
column.setResolved_schema(owner);
|
||||
column.setResolved_table(tableName);
|
||||
column.setResolved_object_type(objectType);
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
String objectType = queryColumnObjectType(owner, tableName);
|
||||
if (objectType != null && !"SYNONYM".equals(objectType)) {
|
||||
throw new SQLException("Column metadata for " + owner + "." + tableName + " (" + objectType + ") is not accessible or not available");
|
||||
}
|
||||
SynonymTarget target = queryColumnSynonym(owner, tableName);
|
||||
if (target == null && objectType == null && depth == 0 && (schema == null || schema.isBlank())) {
|
||||
target = queryColumnSynonym("PUBLIC", tableName);
|
||||
// OceanBase 4.2.5 exposes public synonyms under its internal dictionary owner.
|
||||
if (target == null) target = queryColumnSynonym("__public", tableName);
|
||||
publicFallback = target != null;
|
||||
}
|
||||
if (target == null) {
|
||||
throw new SQLException("Object " + owner + "." + tableName + " not found or not accessible for column metadata");
|
||||
}
|
||||
if (target.databaseLink() != null && !target.databaseLink().isBlank()) {
|
||||
throw new SQLException("Column metadata for DBLink synonyms is not supported: " + owner + "." + tableName);
|
||||
}
|
||||
if (target.owner() == null || target.owner().isBlank() || target.name() == null || target.name().isBlank()) {
|
||||
throw new SQLException("Incomplete synonym target metadata for " + owner + "." + tableName);
|
||||
}
|
||||
owner = target.owner();
|
||||
tableName = target.name();
|
||||
}
|
||||
throw new SQLException("Synonym resolution exceeded 32 links");
|
||||
});
|
||||
}
|
||||
|
||||
private record SynonymTarget(String owner, String name, String databaseLink) {}
|
||||
|
||||
private void verifyPublicColumnTarget(String owner, String name, String targetOwner, String targetName) throws SQLException {
|
||||
// ALL_OBJECTS can hide a foreign-schema object that still shadows a public synonym.
|
||||
// NAME_RESOLVE stops at DBLinks; no remote resolution is performed here.
|
||||
String originalSchema = currentSchema();
|
||||
if (originalSchema.isBlank()) throw new SQLException("Cannot determine current schema for public synonym resolution");
|
||||
boolean switchSchema = !owner.equals(originalSchema);
|
||||
Exception primaryError = null;
|
||||
try {
|
||||
if (switchSchema) {
|
||||
try (var stmt = requireConnection().createStatement()) {
|
||||
stmt.execute(setSchemaSQL(owner));
|
||||
}
|
||||
}
|
||||
try (var stmt = requireConnection().prepareCall("BEGIN DBMS_UTILITY.NAME_RESOLVE(?, ?, ?, ?, ?, ?, ?, ?); END;")) {
|
||||
stmt.setString(1, quoteIdentifier(name));
|
||||
stmt.setInt(2, 0);
|
||||
for (int index = 3; index <= 6; index++) stmt.registerOutParameter(index, Types.VARCHAR);
|
||||
stmt.registerOutParameter(7, Types.NUMERIC);
|
||||
stmt.registerOutParameter(8, Types.NUMERIC);
|
||||
stmt.execute();
|
||||
String databaseLink = stmt.getString(6);
|
||||
if (databaseLink != null && !databaseLink.isBlank()) {
|
||||
throw new SQLException("Column metadata for DBLink synonyms is not supported: " + owner + "." + name);
|
||||
}
|
||||
if (!targetOwner.equals(stmt.getString(3)) || !targetName.equals(stmt.getString(4))) {
|
||||
throw new SQLException("Public synonym target does not match database name resolution for " + owner + "." + name
|
||||
+ "; a local object may be inaccessible");
|
||||
}
|
||||
}
|
||||
} catch (SQLException | RuntimeException error) {
|
||||
primaryError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
if (switchSchema) {
|
||||
try (var stmt = requireConnection().createStatement()) {
|
||||
stmt.execute(setSchemaSQL(originalSchema));
|
||||
} catch (SQLException restoreError) {
|
||||
if (primaryError == null) throw restoreError;
|
||||
primaryError.addSuppressed(restoreError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SynonymTarget queryColumnSynonym(String owner, String name) throws SQLException {
|
||||
String sql = "SELECT TABLE_OWNER, TABLE_NAME, DB_LINK FROM ALL_SYNONYMS WHERE OWNER = ? AND SYNONYM_NAME = ?";
|
||||
try (var stmt = requireConnection().prepareStatement(sql)) {
|
||||
stmt.setString(1, owner);
|
||||
stmt.setString(2, name);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
return rs.next() ? new SynonymTarget(rs.getString("TABLE_OWNER"), rs.getString("TABLE_NAME"), rs.getString("DB_LINK")) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String queryColumnObjectType(String owner, String name) throws SQLException {
|
||||
String sql = """
|
||||
SELECT OBJECT_TYPE FROM ALL_OBJECTS
|
||||
WHERE OWNER = ? AND OBJECT_NAME = ?
|
||||
AND OBJECT_TYPE IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW', 'SYNONYM',
|
||||
'SEQUENCE', 'PROCEDURE', 'FUNCTION', 'PACKAGE', 'TYPE')
|
||||
""";
|
||||
try (var stmt = requireConnection().prepareStatement(sql)) {
|
||||
stmt.setString(1, owner);
|
||||
stmt.setString(2, name);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
String type = null;
|
||||
while (rs.next()) {
|
||||
String candidate = rs.getString("OBJECT_TYPE");
|
||||
if ("MATERIALIZED VIEW".equals(candidate)) return candidate;
|
||||
if (type == null || "VIEW".equals(candidate)) type = candidate;
|
||||
}
|
||||
return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ColumnInfo> getObjectColumns(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
String owner = normalizeSchema(schema);
|
||||
String tableName = normalizeObjectName(table);
|
||||
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OceanBaseOracleSynonymColumnsTest {
|
||||
@Test
|
||||
void privateSynonymReturnsTargetColumnMetadataAcrossSchemas() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
List<ColumnInfo> columns = catalog.agent.getColumns("APP", "ORDER_ALIAS");
|
||||
|
||||
assertEquals(1, columns.size());
|
||||
ColumnInfo column = columns.getFirst();
|
||||
assertEquals("AMOUNT", column.getName());
|
||||
assertEquals("NUMBER(19,4)", column.getData_type());
|
||||
assertEquals(19, column.getNumeric_precision());
|
||||
assertEquals(4, column.getNumeric_scale());
|
||||
assertFalse(column.getIs_nullable());
|
||||
assertTrue(column.getIs_primary_key());
|
||||
assertEquals("0", column.getColumn_default());
|
||||
assertEquals("Order amount", column.getComment());
|
||||
}
|
||||
|
||||
private static Map<String, Object> amountColumn() {
|
||||
return Map.of("COLUMN_NAME", "AMOUNT", "DATA_TYPE", "NUMBER", "NULLABLE", "N",
|
||||
"DATA_PRECISION", 19, "DATA_SCALE", 4, "DATA_LENGTH", 22,
|
||||
"DATA_DEFAULT", "0", "COMMENTS", "Order amount", "IS_PK", 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void synonymChainRetainsQuotedTargetNamesAndVerifiedViewIdentity() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "Bridge.Owner", "Next.Alias", null);
|
||||
catalog.synonym("Bridge.Owner", "Next.Alias", "Sales.Owner", "Order\"View", null);
|
||||
catalog.object("Sales.Owner", "Order\"View", "VIEW");
|
||||
catalog.columns("Sales.Owner", "Order\"View", amountColumn());
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("APP", "ORDER_ALIAS").getFirst();
|
||||
|
||||
assertEquals("Sales.Owner", column.getResolved_schema());
|
||||
assertEquals("Order\"View", column.getResolved_table());
|
||||
assertEquals("VIEW", column.getResolved_object_type());
|
||||
assertEquals("NUMBER(19,4)", column.getData_type());
|
||||
assertFalse(column.getIs_nullable());
|
||||
assertTrue(column.getIs_primary_key());
|
||||
}
|
||||
|
||||
private record Name(String owner, String name) {}
|
||||
|
||||
@Test
|
||||
void publicSynonymResolvesOnlyWhenSchemaIsNotExplicit() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("PUBLIC", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("APP", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("", "PUBLIC_ORDERS").getFirst();
|
||||
assertEquals("SALES", column.getResolved_schema());
|
||||
assertEquals("ORDERS", column.getResolved_table());
|
||||
assertEquals("TABLE", column.getResolved_object_type());
|
||||
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.startsWith("ALTER SESSION")));
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumns("APP", "PUBLIC_ORDERS"));
|
||||
assertTrue(error.getMessage().contains("not found or not accessible"), error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicSynonymSupportsTheOceanBaseDictionaryPublicOwner() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("APP", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("", "PUBLIC_ORDERS").getFirst();
|
||||
|
||||
assertEquals("SALES", column.getResolved_schema());
|
||||
assertEquals("ORDERS", column.getResolved_table());
|
||||
assertEquals("TABLE", column.getResolved_object_type());
|
||||
assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "PUBLIC_ORDERS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedSchemaContextResolvesLocalNamesAndStillAllowsPublicFallback() {
|
||||
var catalog = new Catalog();
|
||||
catalog.columns("Reporting", "LOCAL_ORDERS", amountColumn());
|
||||
catalog.synonym("PUBLIC", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
|
||||
assertEquals("AMOUNT", catalog.agent.getColumnsInContext("", "LOCAL_ORDERS", "Reporting").getFirst().getName());
|
||||
assertEquals("SALES", catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting").getFirst().getResolved_schema());
|
||||
assertEquals("APP", catalog.currentSchema);
|
||||
assertThrows(RuntimeException.class, () -> catalog.agent.getColumnsInContext("Reporting", "PUBLIC_ORDERS", "APP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void localNamespaceObjectsShadowPublicSynonymsEvenWhenTheyHaveNoColumns() {
|
||||
for (String type : List.of("TABLE", "VIEW", "SEQUENCE", "PROCEDURE", "FUNCTION", "PACKAGE", "TYPE")) {
|
||||
var catalog = new Catalog();
|
||||
catalog.object("APP", "ORDERS", type);
|
||||
catalog.synonym("PUBLIC", "ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("", "ORDERS"), type);
|
||||
assertFalse(catalog.columnReads.contains(new Name("SALES", "ORDERS")), type);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void remoteSynonymIsRejectedBeforeAnyTargetQuery() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "REMOTE_ORDERS", "SALES", "ORDERS", "REMOTE.EXAMPLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumns("APP", "REMOTE_ORDERS"));
|
||||
|
||||
assertTrue(error.getMessage().contains("DBLink"), error.getMessage());
|
||||
assertTrue(error.getMessage().contains("not supported"), error.getMessage());
|
||||
assertEquals(List.of(new Name("APP", "REMOTE_ORDERS")), catalog.columnReads);
|
||||
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.contains("@")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cyclicSynonymsFailWithAnExplicitCycleError() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "A", "OTHER", "B", null);
|
||||
catalog.synonym("OTHER", "B", "APP", "A", null);
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "A"));
|
||||
|
||||
assertTrue(error.getMessage().contains("cycle"), error.getMessage());
|
||||
assertEquals(List.of(new Name("APP", "A"), new Name("OTHER", "B")), catalog.columnReads);
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteSynonymTargetMetadataNeverFallsBackToTheSessionOwner() {
|
||||
for (String[] target : new String[][]{{null, "ORDERS"}, {"SALES", null}, {"", "ORDERS"}, {"SALES", ""}}) {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "BROKEN", target[0], target[1], null);
|
||||
catalog.columns("APP", "ORDERS", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "BROKEN"));
|
||||
|
||||
assertTrue(error.getMessage() != null && error.getMessage().contains("Incomplete synonym target metadata"), String.valueOf(error));
|
||||
assertEquals(List.of(new Name("APP", "BROKEN")), catalog.columnReads);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void materializedViewIdentityTakesPrecedenceOverItsTableEntry() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "MV_ALIAS", "SALES", "ORDER_TOTALS", null);
|
||||
catalog.objects.put(new Name("SALES", "ORDER_TOTALS"), List.of(
|
||||
Map.of("OBJECT_TYPE", "TABLE"), Map.of("OBJECT_TYPE", "MATERIALIZED VIEW")));
|
||||
catalog.columns("SALES", "ORDER_TOTALS", amountColumn());
|
||||
|
||||
assertEquals("MATERIALIZED VIEW", catalog.agent.getColumns("APP", "MV_ALIAS").getFirst().getResolved_object_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownTargetTypeIsNotPromotedToTable() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "SALES", "ORDERS", null);
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("APP", "ORDER_ALIAS").getFirst();
|
||||
|
||||
assertEquals("SALES", column.getResolved_schema());
|
||||
assertEquals("ORDERS", column.getResolved_table());
|
||||
assertNull(column.getResolved_object_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void localTableAndPrivateSynonymTakePrecedenceOverPublicSynonym() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("PUBLIC", "ORDERS", "OTHER", "ORDERS", null);
|
||||
catalog.synonym("PUBLIC", "ORDER_ALIAS", "OTHER", "ORDERS", null);
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "APP", "ORDERS", null);
|
||||
catalog.object("APP", "ORDERS", "TABLE");
|
||||
catalog.columns("APP", "ORDERS", amountColumn());
|
||||
|
||||
assertEquals("AMOUNT", catalog.agent.getColumns("", "ORDERS").getFirst().getName());
|
||||
assertEquals("APP", catalog.agent.getColumns("", "ORDER_ALIAS").getFirst().getResolved_schema());
|
||||
assertFalse(catalog.columnReads.contains(new Name("OTHER", "ORDERS")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void brokenPrivateTargetDoesNotFallThroughToPublicSynonyms() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDERS", "SALES", "MISSING", null);
|
||||
catalog.synonym("PUBLIC", "ORDERS", "OTHER", "ORDERS", null);
|
||||
catalog.synonym("PUBLIC", "MISSING", "OTHER", "ORDERS", null);
|
||||
catalog.columns("OTHER", "ORDERS", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("", "ORDERS"));
|
||||
|
||||
assertTrue(error.getMessage().contains("SALES.MISSING not found or not accessible"), error.getMessage());
|
||||
assertFalse(catalog.columnReads.contains(new Name("OTHER", "ORDERS")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAndInaccessibleTargetsAreNotSuccessfulEmptyColumns() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "INACCESSIBLE", "SALES", "HIDDEN", null);
|
||||
|
||||
for (String name : List.of("MISSING", "INACCESSIBLE")) {
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", name));
|
||||
assertTrue(error.getMessage().contains("not found or not accessible"), error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dictionaryFailuresPreserveTheOriginalSqlException() {
|
||||
for (String view : List.of("ALL_TAB_COLUMNS", "ALL_OBJECTS", "ALL_SYNONYMS")) {
|
||||
var catalog = new Catalog();
|
||||
catalog.failure = new SQLException("insufficient privileges", "42000", 1031);
|
||||
catalog.failureView = view;
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "ORDERS"));
|
||||
|
||||
assertSame(catalog.failure, error.getCause(), view);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void longAcyclicChainHasABoundedResolutionLimit() {
|
||||
var catalog = new Catalog();
|
||||
for (int index = 0; index < 40; index++) {
|
||||
catalog.synonym("APP", "S" + index, "APP", "S" + (index + 1), null);
|
||||
}
|
||||
catalog.columns("APP", "S40", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "S0"));
|
||||
|
||||
assertTrue(error.getMessage().contains("exceeded 32 links"), error.getMessage());
|
||||
assertTrue(catalog.columnReads.size() <= 33);
|
||||
assertFalse(catalog.columnReads.contains(new Name("APP", "S40")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invisibleForeignLocalObjectCannotBeReplacedByAPublicTarget() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "Reporting", "PUBLIC_ORDERS", null);
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertTrue(error.getMessage().contains("does not match"), error.getMessage());
|
||||
assertEquals("APP", catalog.currentSchema);
|
||||
}
|
||||
|
||||
private record NativeTarget(String owner, String name, String databaseLink) {}
|
||||
|
||||
@Test
|
||||
void nativePublicResolutionFailureRestoresTheExactOriginalSchema() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.currentSchema = "Original.Owner";
|
||||
catalog.nativeFailure = new SQLException("NAME_RESOLVE not available", "42000", 6550);
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertSame(catalog.nativeFailure, error.getCause());
|
||||
assertEquals("Original.Owner", catalog.currentSchema);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativePublicResolutionRejectsRemoteTargetsWithoutRemoteQueries() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", "REMOTE.EXAMPLE");
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertTrue(error.getMessage().contains("DBLink"), error.getMessage());
|
||||
assertEquals("APP", catalog.currentSchema);
|
||||
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.contains("@")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaRestoreFailureCannotReturnSuccessfulColumns() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.restoreFailure = new SQLException("restore failed");
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertSame(catalog.restoreFailure, error.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaRestoreFailureIsSuppressedOnTheOriginalResolutionError() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.nativeFailure = new SQLException("resolution failed");
|
||||
catalog.restoreFailure = new SQLException("restore failed");
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertSame(catalog.nativeFailure, error.getCause());
|
||||
assertArrayEquals(new Throwable[]{catalog.restoreFailure}, error.getCause().getSuppressed());
|
||||
}
|
||||
|
||||
private static Catalog publicCatalog() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private static final class Catalog {
|
||||
final OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
final Map<Name, List<Map<String, Object>>> columns = new HashMap<>();
|
||||
final Map<Name, List<Map<String, Object>>> synonyms = new HashMap<>();
|
||||
final Map<Name, List<Map<String, Object>>> objects = new HashMap<>();
|
||||
final Map<Name, NativeTarget> nativeTargets = new HashMap<>();
|
||||
final List<String> sql = new ArrayList<>();
|
||||
final List<Name> columnReads = new ArrayList<>();
|
||||
String currentSchema = "APP";
|
||||
SQLException failure;
|
||||
SQLException nativeFailure;
|
||||
SQLException restoreFailure;
|
||||
String failureView;
|
||||
|
||||
Catalog() {
|
||||
Connection connection = proxy(Connection.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("prepareStatement")) return statement((String) args[0]);
|
||||
if (method.getName().equals("prepareCall")) return nativeStatement((String) args[0]);
|
||||
if (method.getName().equals("createStatement")) {
|
||||
return proxy(Statement.class, (stmt, call, params) -> {
|
||||
if (call.getName().equals("executeQuery")) {
|
||||
sql.add((String) params[0]);
|
||||
return rows(List.of(Map.of("CURRENT_SCHEMA", currentSchema)));
|
||||
}
|
||||
if (call.getName().equals("execute")) {
|
||||
String query = (String) params[0];
|
||||
sql.add(query);
|
||||
String prefix = "ALTER SESSION SET CURRENT_SCHEMA = ";
|
||||
assertTrue(query.startsWith(prefix), query);
|
||||
String identifier = query.substring(prefix.length());
|
||||
if (restoreFailure != null && identifier.equals("\"APP\"")) throw restoreFailure;
|
||||
currentSchema = identifier.substring(1, identifier.length() - 1).replace("\"\"", "\"");
|
||||
return false;
|
||||
}
|
||||
return defaultValue(call.getReturnType());
|
||||
});
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
}
|
||||
|
||||
void synonym(String owner, String name, String targetOwner, String targetName, String link) {
|
||||
var row = new HashMap<String, Object>();
|
||||
row.put("TABLE_OWNER", targetOwner);
|
||||
row.put("TABLE_NAME", targetName);
|
||||
row.put("DB_LINK", link);
|
||||
synonyms.put(new Name(owner, name), List.of(row));
|
||||
object(owner, name, "SYNONYM");
|
||||
}
|
||||
|
||||
void object(String owner, String name, String type) {
|
||||
objects.put(new Name(owner, name), List.of(Map.of("OBJECT_TYPE", type)));
|
||||
}
|
||||
|
||||
void columns(String owner, String name, Map<String, Object> column) {
|
||||
columns.put(new Name(owner, name), List.of(column));
|
||||
}
|
||||
|
||||
void nativeResult(String owner, String name, String targetOwner, String targetName, String link) {
|
||||
nativeTargets.put(new Name(owner, name), new NativeTarget(targetOwner, targetName, link));
|
||||
}
|
||||
|
||||
private CallableStatement nativeStatement(String query) {
|
||||
sql.add(query);
|
||||
Map<Integer, Object> parameters = new HashMap<>();
|
||||
NativeTarget[] result = {null};
|
||||
return proxy(CallableStatement.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("setString") || method.getName().equals("setInt")) {
|
||||
parameters.put((Integer) args[0], args[1]);
|
||||
return null;
|
||||
}
|
||||
if (method.getName().equals("execute")) {
|
||||
assertEquals(0, parameters.get(2), "OceanBase table resolution requires context 0");
|
||||
if (nativeFailure != null) throw nativeFailure;
|
||||
String identifier = (String) parameters.get(1);
|
||||
assertTrue(identifier.startsWith("\"") && identifier.endsWith("\""), identifier);
|
||||
String name = identifier.substring(1, identifier.length() - 1).replace("\"\"", "\"");
|
||||
result[0] = nativeTargets.get(new Name(currentSchema, name));
|
||||
assertNotNull(result[0], "Unexpected native resolution for " + currentSchema + "." + name);
|
||||
return false;
|
||||
}
|
||||
if (method.getName().equals("getString")) {
|
||||
return switch ((Integer) args[0]) {
|
||||
case 3 -> result[0].owner();
|
||||
case 4 -> result[0].name();
|
||||
case 6 -> result[0].databaseLink();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private PreparedStatement statement(String query) {
|
||||
sql.add(query);
|
||||
Map<Integer, String> parameters = new HashMap<>();
|
||||
return proxy(PreparedStatement.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("setString")) {
|
||||
parameters.put((Integer) args[0], (String) args[1]);
|
||||
return null;
|
||||
}
|
||||
if (method.getName().equals("executeQuery")) {
|
||||
if (failure != null && query.contains(failureView)) throw failure;
|
||||
Name name = new Name(parameters.get(1), parameters.get(2));
|
||||
if (query.contains("FROM ALL_TAB_COLUMNS")) {
|
||||
assertEquals(name, new Name(parameters.get(3), parameters.get(4)), "PK and column owners must agree");
|
||||
columnReads.add(name);
|
||||
return rows(columns.getOrDefault(name, List.of()));
|
||||
}
|
||||
if (query.contains("FROM ALL_SYNONYMS")) return rows(synonyms.getOrDefault(name, List.of()));
|
||||
if (query.contains("FROM ALL_OBJECTS")) return rows(objects.getOrDefault(name, List.of()));
|
||||
throw new AssertionError("Unexpected metadata query: " + query);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static ResultSet rows(List<Map<String, Object>> data) {
|
||||
int[] index = {-1};
|
||||
boolean[] wasNull = {false};
|
||||
return proxy(ResultSet.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("next")) return ++index[0] < data.size();
|
||||
if (method.getName().equals("wasNull")) return wasNull[0];
|
||||
if (method.getName().equals("getString") || method.getName().equals("getInt") || method.getName().equals("getObject")) {
|
||||
Object value = args[0] instanceof Integer
|
||||
? data.get(index[0]).values().iterator().next() : data.get(index[0]).get(args[0]);
|
||||
wasNull[0] = value == null;
|
||||
if (method.getName().equals("getObject")) return value;
|
||||
if (method.getName().equals("getInt")) return value == null ? 0 : ((Number) value).intValue();
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == boolean.class) return false;
|
||||
if (type == int.class) return 0;
|
||||
if (type == long.class) return 0L;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -41,7 +41,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
|
||||
const store = {
|
||||
getConfig: vi.fn(() => undefined),
|
||||
lookupLocalCompletionTables: vi.fn((): SqlCompletionTable[] => []),
|
||||
lookupLocalCompletionColumns: vi.fn(() => []),
|
||||
lookupLocalCompletionColumns: vi.fn((): SqlCompletionColumn[] => []),
|
||||
lookupLocalCompletionColumnsByPrefix: vi.fn(() => []),
|
||||
lookupLocalCompletionObjects: vi.fn(() => []),
|
||||
lookupLocalCompletionDatabases: vi.fn((): string[] => []),
|
||||
@@ -53,7 +53,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
|
||||
listCompletionSchemas: vi.fn(async (): Promise<string[]> => []),
|
||||
listCompletionDatabases: vi.fn(async (): Promise<string[]> => []),
|
||||
refreshCompletionTables: vi.fn(async () => []),
|
||||
refreshCompletionColumns: vi.fn(async () => []),
|
||||
refreshCompletionColumns: vi.fn(async (): Promise<SqlCompletionColumn[]> => []),
|
||||
refreshCompletionSchemas: vi.fn(async () => []),
|
||||
refreshCompletionDatabases: vi.fn(async () => []),
|
||||
listRedisCompletionCommandDocs: vi.fn(async () => commands),
|
||||
@@ -104,6 +104,32 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
|
||||
return { props, currentView, view, store, settings, runtime, completion, provide, startCompletion };
|
||||
}
|
||||
|
||||
describe("OceanBase INSERT completion", () => {
|
||||
beforeEach(() => vi.useRealTimers());
|
||||
|
||||
it.each([
|
||||
["Alias", undefined],
|
||||
["APP.Alias", "APP"],
|
||||
['"Alias"', undefined],
|
||||
['"MixedOwner"."Alias"', "MixedOwner"],
|
||||
])("resolves %s with the selected schema kept as context", async (source, explicitSchema) => {
|
||||
const { provide, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP", modelValue: `INSERT INTO ${source} (` });
|
||||
store.listCompletionColumns.mockImplementation(async (...args: unknown[]) => (args[3] === explicitSchema ? [{ name: "ID", table: "Alias", schema: explicitSchema }] : []));
|
||||
expect((await provide())?.options.map((option) => option.label)).toContain("ID");
|
||||
expect(store.listCompletionColumns).toHaveBeenCalledWith("connection", "demo", "Alias", explicitSchema, expect.objectContaining({ currentSchema: "APP" }), undefined);
|
||||
if (source.startsWith('"')) expect(store.listCompletionColumns).toHaveBeenCalledWith("connection", "demo", "Alias", explicitSchema, expect.objectContaining({ tableQuoted: true }), undefined);
|
||||
});
|
||||
|
||||
it("keeps synchronous INSERT lookup and background refresh unqualified", async () => {
|
||||
const { provide, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP", modelValue: "INSERT INTO Alias (" });
|
||||
store.lookupLocalCompletionColumns.mockImplementation((...args: unknown[]) => (args[3] === undefined ? [{ name: "ID", table: "Alias" }] : []));
|
||||
store.refreshCompletionColumns.mockResolvedValue([{ name: "ID", table: "Alias" }]);
|
||||
expect((await provide(false))?.options.map((option) => option.label)).toContain("ID");
|
||||
expect(store.lookupLocalCompletionColumns).toHaveBeenCalledWith("connection", "demo", "Alias", undefined, undefined, expect.objectContaining({ currentSchema: "APP" }));
|
||||
expect(store.refreshCompletionColumns).toHaveBeenCalledWith("connection", "demo", "Alias", undefined, expect.objectContaining({ currentSchema: "APP" }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([false, true])("Snowflake namespace completion (semantic=%s)", (semanticCompletionEnabled) => {
|
||||
beforeEach(() => vi.useRealTimers());
|
||||
|
||||
|
||||
+68
@@ -7,6 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata";
|
||||
import type { QueryEditorProps } from "../queryEditorTypes";
|
||||
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
import { buildSelectStarExpansion, buildSqlCompletionItems, getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
|
||||
import { buildSqlSemanticDiagnostics } from "@/lib/sql/semantic/diagnostics";
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
|
||||
vi.mock("@/lib/backend/api", () => ({}));
|
||||
@@ -42,6 +44,72 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
}
|
||||
|
||||
describe("QueryEditor completion metadata ownership", () => {
|
||||
it.each([
|
||||
["Alias", "PUBLIC_ID"],
|
||||
["APP.Alias", "PRIVATE_ID"],
|
||||
['"Alias"', "QUOTED_ID"],
|
||||
])("uses only the cached OceanBase reference %s for star and column completion", async (source, column) => {
|
||||
const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" });
|
||||
store.listCompletionColumns
|
||||
.mockResolvedValueOnce([{ name: "PUBLIC_ID", table: "Alias" }])
|
||||
.mockResolvedValueOnce([{ name: "PRIVATE_ID", table: "Alias", schema: "APP" }])
|
||||
.mockResolvedValueOnce([{ name: "QUOTED_ID", table: "Alias" }]);
|
||||
await metadata.ensureColumnsForTable({ name: "Alias" }, { nameQuoted: false });
|
||||
await metadata.ensureColumnsForTable({ name: "Alias", schema: "APP" });
|
||||
await metadata.ensureColumnsForTable({ name: "Alias" }, { nameQuoted: true });
|
||||
expect(store.listCompletionColumns).toHaveBeenCalledTimes(3);
|
||||
const sql = `SELECT s. FROM ${source} s`;
|
||||
const context = getSqlCompletionContext(sql, 9, { databaseType: "oceanbase-oracle" });
|
||||
expect(buildSelectStarExpansion(context, metadata.cachedColumnsByTable, "oracle", context.qualifier, "oceanbase-oracle")).toBe(`"${column}"`);
|
||||
const items = buildSqlCompletionItems(sql, 9, { tables: [], columnsByTable: metadata.cachedColumnsByTable, databaseType: "oceanbase-oracle", currentSchema: "APP" });
|
||||
expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual([column]);
|
||||
expect(items.find((item) => item.label === "s.*")?.apply).toBe(`"${column}"`);
|
||||
});
|
||||
|
||||
it.each(["Alias", "APP.Alias", '"Alias"'])("uses the same OceanBase cache identity for diagnostics on %s", async (source) => {
|
||||
const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" });
|
||||
store.listCompletionColumns.mockResolvedValue([{ name: "ID", table: "Alias" }]);
|
||||
const sql = `SELECT s.MISSING FROM ${source} s`;
|
||||
const reference = getSqlCompletionContext(sql, 9, { databaseType: "oceanbase-oracle" }).referencedTables[0]!;
|
||||
await metadata.ensureColumnsForTable(reference);
|
||||
const tableStart = sql.indexOf(source);
|
||||
const diagnostics = buildSqlSemanticDiagnostics(
|
||||
{
|
||||
tables: [{ name: reference.name, schema: reference.schema, alias: "s", span: { start_line: 1, start_column: tableStart + 1, end_line: 1, end_column: tableStart + source.length } }],
|
||||
columns: [{ name: "MISSING", qualifier: "s", span: { start_line: 1, start_column: 8, end_line: 1, end_column: 16 } }],
|
||||
},
|
||||
{ tables: [], columnsByTable: metadata.cachedColumnsByTable, loadedColumnTables: metadata.loadedColumnsByTable, sql, databaseType: "oceanbase-oracle", currentSchema: "APP" },
|
||||
);
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual(["Unknown column s.MISSING"]);
|
||||
});
|
||||
|
||||
it("uses scoped synonym columns for JOIN condition completion", async () => {
|
||||
const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" });
|
||||
store.listCompletionColumns.mockResolvedValue([{ name: "TENANT_ID", table: "Alias" }]);
|
||||
await metadata.ensureColumnsForTable({ name: "Alias" });
|
||||
await metadata.ensureColumnsForTable({ name: "Alias", schema: "APP" });
|
||||
const sql = "SELECT * FROM Alias a JOIN APP.Alias b ON ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, { tables: [], columnsByTable: metadata.cachedColumnsByTable, databaseType: "oceanbase-oracle" });
|
||||
expect(items.map((item) => item.label)).toContain("a.TENANT_ID = b.TENANT_ID");
|
||||
});
|
||||
|
||||
it("keeps foreign-key JOIN suggestions available under the same scoped cache keys", () => {
|
||||
const { metadata } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" });
|
||||
metadata.cachedForeignKeysByTable.set(metadata.completionCacheKey({ name: "Alias" }), [{ name: "FK_PARENT", column: "PARENT", ref_table: "OtherAlias", ref_column: "ID" }]);
|
||||
const sql = "SELECT * FROM Alias a JOIN OtherAlias b ON ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, { tables: [], columnsByTable: metadata.cachedColumnsByTable, foreignKeysByTable: metadata.cachedForeignKeysByTable, databaseType: "oceanbase-oracle" });
|
||||
expect(items.map((item) => item.label)).toContain("a.PARENT = b.ID");
|
||||
});
|
||||
|
||||
it("keeps an OceanBase selected schema separate from an explicitly qualified synonym", async () => {
|
||||
const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "MixedOwner" });
|
||||
store.listCompletionColumns.mockResolvedValue(columns);
|
||||
await metadata.ensureColumnsForTable({ name: "Alias" });
|
||||
expect(store.listCompletionColumns).toHaveBeenLastCalledWith("connection", "demo", "Alias", undefined, expect.objectContaining({ currentSchema: "MixedOwner" }), undefined);
|
||||
await metadata.ensureColumnsForTable({ name: "Alias", schema: "MixedOwner" });
|
||||
expect(store.listCompletionColumns).toHaveBeenLastCalledWith("connection", "demo", "Alias", "MixedOwner", expect.objectContaining({ currentSchema: "MixedOwner" }), undefined);
|
||||
});
|
||||
|
||||
it.each(["getEditorSqlCompletionContext", "getEditorSemanticModel"] as const)("keys %s by document, editor state, position and dialect", (method) => {
|
||||
const { metadata, props } = createHarness();
|
||||
const state = EditorState.create({ doc: props.modelValue });
|
||||
|
||||
+24
-11
@@ -42,6 +42,16 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
const view = shallowRef<EditorView | null>(currentView);
|
||||
const runtime: Options["runtime"] = { editorIsActive: true, executableStatementRangeCache: null, setSqlDiagnosticsEffect: StateEffect.define<SqlSemanticDiagnostic[]>(), diagnosticComp: null, buildSqlDiagnosticExtension: null, codeMirrorCompletionStatus: () => null };
|
||||
const settingsStore = { editorSettings: { sqlSemanticDiagnosticsEnabled: true } } as Options["settingsStore"];
|
||||
const metadata = {
|
||||
cachedTables: [],
|
||||
cachedColumnsByTable: new Map(),
|
||||
loadedColumnsByTable: new Set<string>(),
|
||||
usesOracleSessionCompletionColumns: () => false,
|
||||
findExactSemanticDiagnosticTable: vi.fn().mockResolvedValue(null),
|
||||
completionCacheKey: (table: { name: string }) => table.name,
|
||||
ensureColumnsForTable: vi.fn().mockResolvedValue(false),
|
||||
isMissingTableMetadataError: () => false,
|
||||
};
|
||||
let diagnostics!: ReturnType<typeof useQueryEditorDiagnostics>;
|
||||
const host = document.createElement("div");
|
||||
const app = createApp({
|
||||
@@ -57,16 +67,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
sqlBehaviorDialect: () => props.dialect,
|
||||
semanticCompletionEnabled: false,
|
||||
maxCompletionTables: 100,
|
||||
metadata: {
|
||||
cachedTables: [],
|
||||
cachedColumnsByTable: new Map(),
|
||||
loadedColumnsByTable: new Set(),
|
||||
usesOracleSessionCompletionColumns: () => false,
|
||||
findExactSemanticDiagnosticTable: vi.fn().mockResolvedValue(null),
|
||||
completionCacheKey: (table) => table.name,
|
||||
ensureColumnsForTable: vi.fn().mockResolvedValue(false),
|
||||
isMissingTableMetadataError: () => false,
|
||||
},
|
||||
metadata,
|
||||
});
|
||||
return () => h("div");
|
||||
},
|
||||
@@ -83,10 +84,22 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
currentView.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
return { diagnostics, props, runtime, settingsStore, currentView, unmount };
|
||||
return { diagnostics, props, runtime, settingsStore, currentView, unmount, metadata };
|
||||
}
|
||||
|
||||
describe("QueryEditor diagnostic scheduling ownership", () => {
|
||||
it("preserves the unqualified quoted OceanBase reference and selected schema through metadata loading", async () => {
|
||||
const sql = 'SELECT MISSING FROM "Alias"';
|
||||
const { diagnostics, metadata } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP", modelValue: sql });
|
||||
analyze.mockResolvedValue({ tables: [{ name: "Alias", span: { start_line: 1, start_column: 21, end_line: 1, end_column: 27 } }], columns: [] });
|
||||
metadata.findExactSemanticDiagnosticTable.mockResolvedValue({ name: "Alias", schema: "PUBLIC" });
|
||||
diagnostics.scheduleSemanticDiagnostics(0);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(metadata.ensureColumnsForTable).toHaveBeenCalledWith(expect.objectContaining({ name: "Alias", nameQuoted: true }), undefined, expect.objectContaining({ schema: "APP" }));
|
||||
expect(metadata.ensureColumnsForTable.mock.calls[0]![0].schema).toBeUndefined();
|
||||
expect(buildDiagnostics).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ currentSchema: "APP" }));
|
||||
});
|
||||
|
||||
it("coalesces timers and shares the latest executable statement cache", async () => {
|
||||
const { diagnostics, currentView, runtime } = createHarness();
|
||||
diagnostics.scheduleSemanticDiagnostics(50);
|
||||
|
||||
@@ -1105,11 +1105,13 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
|
||||
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||
if (completionContext.insertTable) {
|
||||
const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? scope.database;
|
||||
const insertSchema = completionContext.insertSchema ?? scope.schema;
|
||||
const insertColumns = usesOracleSessionCompletionColumns(insertSchema) ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, insertDatabase, completionContext.insertTable, insertSchema, props.catalog);
|
||||
const insertTable = insertCompletionTable(completionContext);
|
||||
const target = insertCompletionMetadataTarget(insertTable, scope);
|
||||
const insertKey = completionCacheKey(insertTable, scope);
|
||||
const insertColumns =
|
||||
cachedColumnsByTable.get(insertKey) ?? (usesOracleSessionCompletionColumns(target.schema) ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, insertTable.name, target.schema, target.catalog, completionColumnRequestContext(insertTable)));
|
||||
if (insertColumns.length > 0) {
|
||||
columnsByTable.set(completionCacheKey({ name: completionContext.insertTable, database: completionContext.insertDatabase, schema: insertSchema }, scope), insertColumns);
|
||||
columnsByTable.set(insertKey, insertColumns);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1287,12 +1289,11 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
}
|
||||
}
|
||||
if (!onDemandOnlyColumns && completionContext.insertTable) {
|
||||
const insertTable = completionContext.insertTable;
|
||||
const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? database;
|
||||
void refreshCompletionColumnsForEditor(connectionId, insertDatabase, insertTable, completionContext.insertSchema ?? scope.schema)
|
||||
const insertTable = insertCompletionTable(completionContext);
|
||||
const target = insertCompletionMetadataTarget(insertTable, scope);
|
||||
void refreshCompletionColumnsForEditor(connectionId, target.database, insertTable.name, target.schema, target.catalog, insertTable)
|
||||
.then((columns) => {
|
||||
const insertSchema = completionContext.insertSchema ?? scope.schema;
|
||||
cachedColumnsByTable.set(completionCacheKey({ name: insertTable, database: completionContext.insertDatabase, schema: insertSchema }, scope), columns);
|
||||
cachedColumnsByTable.set(completionCacheKey(insertTable, scope), columns);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -1412,6 +1413,15 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
return ["routine"];
|
||||
}
|
||||
|
||||
function insertCompletionTable(context: ReturnType<typeof getSqlCompletionContext>) {
|
||||
return { name: context.insertTable!, database: context.insertDatabase, schema: context.insertSchema, nameQuoted: context.insertTableQuoted, schemaQuoted: context.insertSchemaQuoted };
|
||||
}
|
||||
|
||||
function insertCompletionMetadataTarget(table: ReturnType<typeof insertCompletionTable>, scope: CompletionMetadataScope) {
|
||||
if (props.databaseType === "oceanbase-oracle") return completionMetadataTarget(table, scope)!;
|
||||
return { database: (supportsDatabaseSchemaQualifierCompletion() ? table.database : undefined) ?? scope.database, schema: table.schema ?? scope.schema, catalog: props.catalog };
|
||||
}
|
||||
|
||||
async function performAsyncCompletionWithResult(epoch: number, completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number, scope: CompletionMetadataScope) {
|
||||
const localOnlyMetadata = usesLocalOnlyCompletionMetadata();
|
||||
const onDemandOnlyColumns = usesOnDemandOnlyCompletionColumns();
|
||||
@@ -1419,12 +1429,12 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
let insertColumnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||
if (completionContext.insertTable) {
|
||||
try {
|
||||
const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? scope.database;
|
||||
const insertCols = await listCompletionColumnsForEditor(props.connectionId!, insertDatabase, completionContext.insertTable, completionContext.insertSchema ?? scope.schema);
|
||||
const insertTable = insertCompletionTable(completionContext);
|
||||
const target = insertCompletionMetadataTarget(insertTable, scope);
|
||||
const insertCols = await listCompletionColumnsForEditor(props.connectionId!, target.database, insertTable.name, target.schema, target.catalog, insertTable);
|
||||
if (epoch !== completionEpoch) return null;
|
||||
if (insertCols.length > 0) {
|
||||
const insertSchema = completionContext.insertSchema ?? scope.schema;
|
||||
const insertKey = completionCacheKey({ name: completionContext.insertTable, database: completionContext.insertDatabase, schema: insertSchema }, scope);
|
||||
const insertKey = completionCacheKey(insertTable, scope);
|
||||
insertColumnsByTable.set(insertKey, insertCols);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { EditorState, Text } from "@codemirror/state";
|
||||
import type { CompletionMetadataScope } from "./queryEditorTypes";
|
||||
import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns";
|
||||
import { COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore";
|
||||
import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
|
||||
import { getSqlCompletionContext, oceanBaseCompletionCacheKey } from "@/lib/sql/sqlCompletion";
|
||||
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
|
||||
import type { SqlSemanticModel } from "@/lib/sql/semantic/types";
|
||||
import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis";
|
||||
@@ -216,6 +216,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
|
||||
version: props.completionContextVersion,
|
||||
tableQuoted: reference?.nameQuoted,
|
||||
schemaQuoted: reference?.schemaQuoted,
|
||||
...(props.databaseType === "oceanbase-oracle" ? { currentSchema: props.schema } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,6 +246,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
|
||||
}
|
||||
|
||||
function completionCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope?: CompletionMetadataScope) {
|
||||
if (props.databaseType === "oceanbase-oracle") return oceanBaseCompletionCacheKey(table, scope?.schema ?? props.schema);
|
||||
const schema = table.schema ?? scope?.schema ?? props.schema;
|
||||
const scopedDatabase = scope && scope.database !== props.database ? scope.database : undefined;
|
||||
const database = supportsDatabaseSchemaQualifierCompletion() ? (table.database ?? scopedDatabase) : undefined;
|
||||
@@ -361,7 +363,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
|
||||
// unqualified table name. The query editor commonly has no schema selected
|
||||
// when the user is working from a database-level tab, so use the same
|
||||
// default as the table/DDL metadata paths instead of returning no columns.
|
||||
const selectedSchema = table.schema ?? scope?.schema ?? props.schema;
|
||||
const selectedSchema = props.databaseType === "oceanbase-oracle" ? table.schema : (table.schema ?? scope?.schema ?? props.schema);
|
||||
const effectiveSchema = selectedSchema ?? (props.databaseType === "sqlserver" ? metadataSchemaForConnection(connectionStore.getConfig(props.connectionId ?? ""), currentDatabase, undefined) : undefined);
|
||||
if (supportsDatabaseSchemaQualifierCompletion() && table.database) {
|
||||
return { database: table.database, schema: effectiveSchema, catalog: table.catalog ?? props.catalog };
|
||||
@@ -413,14 +415,14 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
|
||||
return remoteMatches.find((item) => completionTablesMatch(item, table)) ?? null;
|
||||
}
|
||||
|
||||
async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null }, reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">, scope?: CompletionMetadataScope): Promise<boolean> {
|
||||
async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, reference: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted"> = table, scope?: CompletionMetadataScope): Promise<boolean> {
|
||||
if (isVirtualCompletionTableReference(table)) return false;
|
||||
const cacheKey = completionCacheKey(table, scope);
|
||||
const cacheKey = completionCacheKey({ ...table, ...reference }, scope);
|
||||
if (cachedColumnsByTable.has(cacheKey)) return true;
|
||||
if (!props.connectionId || props.database == null) return false;
|
||||
const target = completionMetadataTarget(table, scope);
|
||||
if (!target) return false;
|
||||
const localColumns = connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, table.name, target.schema, target.catalog, completionColumnRequestContext(reference));
|
||||
const localColumns = props.databaseType === "oceanbase-oracle" && !table.schema ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, table.name, target.schema, target.catalog, completionColumnRequestContext(reference));
|
||||
if (localColumns.length > 0) {
|
||||
cachedColumnsByTable.set(cacheKey, localColumns);
|
||||
loadedColumnsByTable.add(cacheKey.toLowerCase());
|
||||
@@ -433,7 +435,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
|
||||
// local/remote table cache and retry with its schema before reporting that
|
||||
// star expansion is unavailable. This is especially important for aliased
|
||||
// sources because the alias itself must never be sent as the table name.
|
||||
if (columns.length === 0 && !table.schema && !target.schema && !supportsDatabaseQualifierCompletion()) {
|
||||
if (columns.length === 0 && props.databaseType !== "oceanbase-oracle" && !table.schema && !target.schema && !supportsDatabaseQualifierCompletion()) {
|
||||
const schemaCandidates: string[] = [];
|
||||
const seenSchemas = new Set<string>();
|
||||
const addSchema = (schema?: string | null) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
buildSqlSemanticDiagnostics,
|
||||
isSqlSemanticDiagnosticInputContext,
|
||||
isSqlVirtualTableReference,
|
||||
oceanBaseDiagnosticTableReference,
|
||||
shouldRunSqlSemanticDiagnostics,
|
||||
sqlSemanticDiagnosticRangesForViewport,
|
||||
sqlServerRoutineDefinitionRangesForViewport,
|
||||
@@ -259,7 +260,8 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
};
|
||||
}
|
||||
|
||||
function semanticDiagnosticTablesForScope(tables: SqlTableReference[], scope: CompletionMetadataScope): SqlTableReference[] {
|
||||
function semanticDiagnosticTablesForScope(tables: SqlTableReference[], scope: CompletionMetadataScope, sql: string): SqlTableReference[] {
|
||||
if (props.databaseType === "oceanbase-oracle") return tables.map((table) => oceanBaseDiagnosticTableReference(table, sql));
|
||||
if (props.databaseType !== "sqlserver" || scope.database === props.database) return tables;
|
||||
return tables.map((table) => (table.database ? table : { ...table, database: scope.database, schema: table.schema ?? scope.schema }));
|
||||
}
|
||||
@@ -274,7 +276,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
enriched.push(table);
|
||||
continue;
|
||||
}
|
||||
if (metadata.usesOracleSessionCompletionColumns(table.schema)) {
|
||||
if ((props.databaseType === "oceanbase-oracle" && !table.schema) || metadata.usesOracleSessionCompletionColumns(table.schema)) {
|
||||
enriched.push(table);
|
||||
continue;
|
||||
}
|
||||
@@ -448,7 +450,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
const metadataScope = semanticDiagnosticMetadataScope(sql, range);
|
||||
const scopedAnalysis = {
|
||||
...semanticAnalysis,
|
||||
tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope),
|
||||
tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope, range.sql),
|
||||
};
|
||||
const { tables, missingTables } = await enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope);
|
||||
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables, metadataScope);
|
||||
@@ -468,6 +470,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
loadedColumnTables: metadata.loadedColumnsByTable,
|
||||
sql: range.sql,
|
||||
databaseType: props.databaseType,
|
||||
currentSchema: metadataScope.schema,
|
||||
}),
|
||||
range,
|
||||
sql,
|
||||
@@ -582,7 +585,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
const metadataScope = semanticDiagnosticMetadataScope(sql, range);
|
||||
const scopedAnalysis = {
|
||||
...semanticAnalysis,
|
||||
tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope),
|
||||
tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope, range.sql),
|
||||
};
|
||||
const { tables, missingTables } = await enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope);
|
||||
const loadedColumnsBefore = metadata.loadedColumnsByTable.size;
|
||||
@@ -603,6 +606,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
loadedColumnTables: metadata.loadedColumnsByTable,
|
||||
sql: range.sql,
|
||||
databaseType: props.databaseType,
|
||||
currentSchema: metadataScope.schema,
|
||||
},
|
||||
),
|
||||
range,
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
|
||||
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/transfer/QueryResultTransferDialog.vue", () => ({ __esModule: true, default: { render: () => null } }));
|
||||
const mocks = vi.hoisted(() => ({ openTableStructureEditor: vi.fn(() => true) }));
|
||||
vi.mock("@/components/grid/DataGrid.vue", () => ({
|
||||
__esModule: true,
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("Oracle session-scoped completion", () => {
|
||||
expect(usesOracleSessionCompletionColumns({ databaseType: "oracle", clientSessionId: "tab-a", referenceSchema: "REPORTING" })).toBe(false);
|
||||
expect(usesOracleSessionCompletionColumns({ databaseType: "oceanbase-oracle", clientSessionId: "tab-a", referenceSchema: "REPORTING" })).toBe(false);
|
||||
expect(usesOracleSessionCompletionColumns({ databaseType: "oracle", clientSessionId: "tab-a", selectedSchema: "REPORTING" })).toBe(false);
|
||||
expect(usesOracleSessionCompletionColumns({ databaseType: "oceanbase-oracle", clientSessionId: "tab-a", selectedSchema: "REPORTING" })).toBe(false);
|
||||
expect(usesOracleSessionCompletionColumns({ databaseType: "oceanbase-oracle", clientSessionId: "tab-a", selectedSchema: "REPORTING" })).toBe(true);
|
||||
expect(usesOracleSessionCompletionColumns({ databaseType: "postgres", clientSessionId: "tab-a" })).toBe(false);
|
||||
expect(usesOracleSessionCompletionColumns({ databaseType: "oracle" })).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1241,8 +1241,8 @@ export async function getCustomTypeDetails(connectionId: string, database: strin
|
||||
return get(`/api/schema/custom-type-details?${qs({ connection_id: connectionId, database, schema, table: name })}`);
|
||||
}
|
||||
|
||||
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise<ColumnInfo[]> {
|
||||
return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog, client_session_id: clientSessionId })}`);
|
||||
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string, currentSchema?: string): Promise<ColumnInfo[]> {
|
||||
return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog, client_session_id: clientSessionId, current_schema: currentSchema })}`);
|
||||
}
|
||||
|
||||
export async function getPluginTableMetadata(request: PluginTableMetadataRequest): Promise<PluginTableMetadata> {
|
||||
|
||||
@@ -1791,7 +1791,7 @@ export async function listSchemaInfos(connectionId: string, database: string): P
|
||||
export async function getCustomTypeDetails(connectionId: string, database: string, schema: string, name: string): Promise<CustomTypeDetails> {
|
||||
return invoke("get_custom_type_details", { connectionId, database, schema, name });
|
||||
}
|
||||
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise<ColumnInfo[]> {
|
||||
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string, currentSchema?: string): Promise<ColumnInfo[]> {
|
||||
return invoke("get_columns", {
|
||||
connectionId,
|
||||
database,
|
||||
@@ -1799,6 +1799,7 @@ export async function getColumns(connectionId: string, database: string, schema:
|
||||
table,
|
||||
catalog,
|
||||
clientSessionId,
|
||||
currentSchema,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,55 @@ describe("tableMetadataCache columns facet request counts", () => {
|
||||
expect(mocks.getColumns).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses a synonym's exact resolved target for indexes while retaining its source name", async () => {
|
||||
mocks.getColumns.mockResolvedValue([{ ...column("ItemId"), is_primary_key: false, resolved_schema: "MixedOwner", resolved_table: "ActualItems", resolved_object_type: "TABLE" }]);
|
||||
mocks.listIndexes.mockResolvedValue([{ name: "ITEMS_PK", columns: ["ItemId"], is_primary: true, is_unique: true }]);
|
||||
const request = { connectionId: "ob-1", database: "service", schema: "AppOwner", tableName: "ItemsAlias", tableType: "SYNONYM", databaseType: "oceanbase-oracle" };
|
||||
|
||||
const result = await loadTableMetadata(request);
|
||||
|
||||
expect(mocks.listIndexes).toHaveBeenCalledExactlyOnceWith("ob-1", "service", "MixedOwner", "ActualItems", undefined);
|
||||
expect(result.metadata).toMatchObject({ schema: "AppOwner", tableName: "ItemsAlias", tableType: "TABLE", primaryKeys: ["ItemId"] });
|
||||
expect((await loadTableColumns(request)).tableType).toBe("TABLE");
|
||||
});
|
||||
|
||||
it("keeps unqualified synonym column metadata separate for different current schemas", async () => {
|
||||
const request = { connectionId: "ob-1", database: "service", tableName: "ITEMS", databaseType: "oceanbase-oracle", currentSchema: "FirstOwner" };
|
||||
mocks.getColumns.mockImplementation(async (_c, _d, _s, _t, _catalog, _session, currentSchema) => [{ ...column(currentSchema === "SecondOwner" ? "SecondId" : "FirstId"), resolved_schema: currentSchema, resolved_table: "Items", resolved_object_type: "TABLE" }]);
|
||||
|
||||
const first = await loadTableColumns(request);
|
||||
const second = await loadTableColumns({ ...request, currentSchema: "SecondOwner" });
|
||||
const cached = await loadTableColumns(request);
|
||||
|
||||
expect(first.columns[0]?.name).toBe("FirstId");
|
||||
expect(second.columns[0]?.name).toBe("SecondId");
|
||||
expect(cached.columns[0]?.name).toBe("FirstId");
|
||||
expect(mocks.getColumns).toHaveBeenNthCalledWith(1, "ob-1", "service", "", "ITEMS", undefined, undefined, "FirstOwner");
|
||||
expect(mocks.getColumns).toHaveBeenNthCalledWith(2, "ob-1", "service", "", "ITEMS", undefined, undefined, "SecondOwner");
|
||||
expect(mocks.getColumns).toHaveBeenCalledTimes(2);
|
||||
|
||||
invalidateTableMetadataCache({ connectionId: "ob-1", database: "service", schema: "FirstOwner", tableName: "ITEMS" });
|
||||
await loadTableColumns(request);
|
||||
await loadTableColumns({ ...request, currentSchema: "SecondOwner" });
|
||||
expect(mocks.getColumns).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("uses the current schema for parallel index discovery on a known direct table", async () => {
|
||||
let releaseColumns: (value: ColumnInfo[]) => void = () => {};
|
||||
mocks.getColumns.mockReturnValueOnce(
|
||||
new Promise<ColumnInfo[]>((resolve) => {
|
||||
releaseColumns = resolve;
|
||||
}),
|
||||
);
|
||||
const load = loadTableMetadata({ connectionId: "ob-1", database: "service", schema: "", currentSchema: "SelectedOwner", tableName: "Items", tableType: "TABLE", databaseType: "oceanbase-oracle" });
|
||||
|
||||
await flush();
|
||||
|
||||
expect(mocks.listIndexes).toHaveBeenCalledExactlyOnceWith("ob-1", "service", "SelectedOwner", "Items", undefined);
|
||||
releaseColumns([column("ID")]);
|
||||
expect((await load).metadata.primaryKeys).toEqual(["ID"]);
|
||||
});
|
||||
|
||||
it("R1 — cold single-source columns load: 1 getColumns, 0 listIndexes", async () => {
|
||||
const result = await loadTableColumns({ ...usersRequest });
|
||||
expect(result.columns[0]?.name).toBe("users");
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface TableMetadataRequest {
|
||||
tableName: string;
|
||||
tableType?: string;
|
||||
databaseType: DatabaseType | string;
|
||||
currentSchema?: string;
|
||||
driverProfile?: string;
|
||||
catalog?: string;
|
||||
force?: boolean;
|
||||
@@ -156,16 +157,16 @@ function bumpTableMetadataInvalidationStamp(scopeKey: string): void {
|
||||
tableMetadataInvalidationStamps.set(scopeKey, (tableMetadataInvalidationStamps.get(scopeKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
export function tableMetadataScope(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">): MetadataScopeInput {
|
||||
export function tableMetadataScope(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog" | "currentSchema">): MetadataScopeInput {
|
||||
return {
|
||||
kind: "table-metadata",
|
||||
connectionId: request.connectionId,
|
||||
database: request.database,
|
||||
schema: request.schema ?? "",
|
||||
schema: request.schema || request.currentSchema || "",
|
||||
tableName: request.tableName,
|
||||
tableType: request.tableType,
|
||||
driverProfile: request.driverProfile || request.databaseType,
|
||||
extra: request.catalog ? { catalog: request.catalog } : undefined,
|
||||
extra: request.catalog || request.currentSchema ? { catalog: request.catalog, currentSchema: request.currentSchema } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,13 +177,13 @@ function withVirtualRowIdentifier(metadata: TableMetadata, request: Pick<TableMe
|
||||
return { ...metadata, primaryKeys: virtualPrimaryKeys, virtualPrimaryKeys, rowIdentityResolved: true };
|
||||
}
|
||||
|
||||
export function getCachedTableMetadata(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">): TableMetadataLoadResult | undefined {
|
||||
export function getCachedTableMetadata(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog" | "currentSchema">): TableMetadataLoadResult | undefined {
|
||||
const hit = tableMetadataCache.get(tableMetadataScope(request));
|
||||
if (!hit) return undefined;
|
||||
return { metadata: withVirtualRowIdentifier(hit.value, request), cacheStatus: hit.stale ? "stale" : "hit", ageMs: hit.ageMs };
|
||||
}
|
||||
|
||||
export function updateCachedTableMetadataType(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">, tableType: string): boolean {
|
||||
export function updateCachedTableMetadataType(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog" | "currentSchema">, tableType: string): boolean {
|
||||
const scope = tableMetadataScope(request);
|
||||
const hit = tableMetadataCache.get(scope);
|
||||
if (!hit) return false;
|
||||
@@ -190,7 +191,7 @@ export function updateCachedTableMetadataType(request: Pick<TableMetadataRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getCachedTableColumns(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">): TableColumnsLoadResult | undefined {
|
||||
export function getCachedTableColumns(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog" | "currentSchema">): TableColumnsLoadResult | undefined {
|
||||
const hit = tableColumnsCache.get(tableMetadataScope(request));
|
||||
if (!hit) return undefined;
|
||||
return { columns: hit.value.columns, tableType: hit.value.tableType, cacheStatus: hit.stale ? "stale" : "hit", ageMs: hit.ageMs, cachedAt: hit.cachedAt };
|
||||
@@ -247,11 +248,14 @@ export async function loadTableColumns(request: TableMetadataRequest): Promise<T
|
||||
scope,
|
||||
async () => {
|
||||
// Display-only loader: columns only, never index discovery.
|
||||
const columns = await api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog);
|
||||
const columns = request.currentSchema
|
||||
? await api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog, undefined, request.currentSchema)
|
||||
: await api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog);
|
||||
const resolvedTarget = columns.find((column) => column.resolved_table);
|
||||
return {
|
||||
schema: request.schema || undefined,
|
||||
tableName: request.tableName,
|
||||
tableType: request.tableType,
|
||||
tableType: resolvedTarget ? resolvedTarget.resolved_object_type?.trim() || "UNKNOWN" : request.tableType,
|
||||
catalog: request.catalog,
|
||||
database: request.database,
|
||||
columns,
|
||||
@@ -300,7 +304,7 @@ export async function loadTableIndexes(request: TableMetadataRequest): Promise<I
|
||||
if (!oldestKey) break;
|
||||
tableIndexesLoads.delete(oldestKey);
|
||||
}
|
||||
const promise = api.listIndexes(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog);
|
||||
const promise = api.listIndexes(request.connectionId, request.database, request.schema || request.currentSchema || "", request.tableName, request.catalog);
|
||||
const entry = { parts: metadataScopeParts(scope), promise, expiresAt: Date.now() + TABLE_METADATA_CACHE_TTL_MS };
|
||||
tableIndexesLoads.set(scopeKey, entry);
|
||||
void promise.catch(() => {
|
||||
@@ -339,25 +343,28 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise<
|
||||
metadata = await tableMetadataCoordinator.run(
|
||||
scope,
|
||||
async () => {
|
||||
// Column discovery can be especially slow on Oracle. Start row-identity
|
||||
// discovery independently unless an agent-backed PostgreSQL-family
|
||||
// relation must first report its visible schema for the index lookup.
|
||||
// Synonyms can resolve to a different owner and object name. Wait for
|
||||
// their columns, while known base tables retain concurrent discovery.
|
||||
const resolveReportedSchema = (request.databaseType === "vastbase" || request.databaseType === "kingbase") && !request.schema;
|
||||
const resolveSynonymTarget = (request.databaseType === "oracle" || request.databaseType === "oceanbase-oracle" || request.driverProfile === "oracle" || request.driverProfile === "oceanbase-oracle") && request.tableType?.trim().toUpperCase() !== "TABLE";
|
||||
let rowIdentityResolved = true;
|
||||
const indexFailure = (): IndexInfo[] => {
|
||||
rowIdentityResolved = false;
|
||||
return [];
|
||||
};
|
||||
const indexesPromise = resolveReportedSchema ? undefined : loadTableIndexes(request).catch(indexFailure);
|
||||
const indexesPromise = resolveReportedSchema || resolveSynonymTarget ? undefined : loadTableIndexes(request).catch(() => undefined);
|
||||
const columnsResult = await columnsPromise;
|
||||
const columns = columnsResult.columns;
|
||||
const resolvedTarget = columns.find((column) => column.resolved_table);
|
||||
const resolvedSchema = resolveReportedSchema ? columns.find((column) => column.resolved_schema)?.resolved_schema : request.schema;
|
||||
const indexes = columns.length > 0 ? await (indexesPromise ?? loadTableIndexes({ ...request, schema: resolvedSchema }).catch(indexFailure)) : [];
|
||||
const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, request.tableType);
|
||||
const indexRequest = { ...request, schema: resolvedTarget?.resolved_schema ?? (resolvedSchema || request.currentSchema), tableName: resolvedTarget?.resolved_table ?? request.tableName };
|
||||
const loadedIndexes = columns.length > 0 ? await ((!resolvedTarget && indexesPromise) || loadTableIndexes(indexRequest).catch(() => undefined)) : [];
|
||||
const indexes = loadedIndexes ?? indexFailure();
|
||||
const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, columnsResult.tableType);
|
||||
return {
|
||||
schema: resolvedSchema || undefined,
|
||||
tableName: request.tableName,
|
||||
tableType: request.tableType,
|
||||
tableType: columnsResult.tableType,
|
||||
catalog: request.catalog,
|
||||
database: request.database,
|
||||
columns,
|
||||
|
||||
@@ -10,5 +10,6 @@ export function usesOracleCurrentSchemaCompletion(databaseType?: DatabaseType, s
|
||||
}
|
||||
|
||||
export function usesOracleSessionCompletionColumns(options: { databaseType?: DatabaseType; selectedSchema?: string; referenceSchema?: string | null; clientSessionId?: string }): boolean {
|
||||
return usesOracleCurrentSchemaCompletion(options.databaseType, options.referenceSchema || options.selectedSchema) && !!options.clientSessionId;
|
||||
const schema = options.databaseType === "oceanbase-oracle" ? options.referenceSchema : options.referenceSchema || options.selectedSchema;
|
||||
return usesOracleCurrentSchemaCompletion(options.databaseType, schema) && !!options.clientSessionId;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
import { getSqlCompletionContext, isOracleSystemValueName } from "@/lib/sql/sqlCompletion";
|
||||
import { getSqlCompletionContext, isOracleSystemValueName, matchesOceanBaseCompletionCacheKey } from "@/lib/sql/sqlCompletion";
|
||||
import { tokenizeSqlSemantic, tokenIsIdentifier } from "@/lib/sql/semantic/tokens";
|
||||
import { executableStatementRanges, keepsOracleStyleBlockTogether, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { DBX_TDENGINE_TBNAME_COLUMN, isTdengineStableTableType } from "@/lib/table/tableEditing";
|
||||
import type { DatabaseType, SqlColumnReference, SqlGroupByViolation, SqlReferenceAnalysis, SqlReferenceScope, SqlTableReference, SqlTextSpan } from "@/types/database";
|
||||
@@ -17,6 +18,7 @@ export interface SqlSemanticDiagnosticSchema {
|
||||
loadedColumnTables?: Set<string>;
|
||||
sql?: string;
|
||||
databaseType?: DatabaseType;
|
||||
currentSchema?: string;
|
||||
}
|
||||
|
||||
export interface SqlSemanticDiagnosticVisibleRange {
|
||||
@@ -265,7 +267,7 @@ function sqlServerStatementNeedsBatchContext(sql: string): boolean {
|
||||
|
||||
export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, schema: SqlSemanticDiagnosticSchema): SqlSemanticDiagnostic[] {
|
||||
const diagnostics: SqlSemanticDiagnostic[] = [];
|
||||
const tables = analysis.tables.filter((table) => table.name.trim());
|
||||
const tables = analysis.tables.filter((table) => table.name.trim()).map((table) => (schema.databaseType === "oceanbase-oracle" && schema.sql ? oceanBaseDiagnosticTableReference(table, schema.sql) : table));
|
||||
const knownTables = new Map<string, SqlTableReference>();
|
||||
const scopesById = scopesByIdMap(analysis.scopes);
|
||||
let tdengineStableTables: Set<string> | undefined;
|
||||
@@ -292,7 +294,7 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
|
||||
if (!table) continue;
|
||||
if (schema.missingTables?.has(tableReferenceKey(table))) continue;
|
||||
|
||||
const columns = columnsForTable(table, schema.columnsByTable, schema.loadedColumnTables);
|
||||
const columns = columnsForTable(table, schema.columnsByTable, schema.loadedColumnTables, schema.currentSchema);
|
||||
if (!columns) continue;
|
||||
|
||||
const columnNames = new Set(columns.map((item) => normalizeName(item.name)));
|
||||
@@ -592,7 +594,13 @@ function statementIndexAt(sql: string, offset: number): number {
|
||||
return statementIndex;
|
||||
}
|
||||
|
||||
function columnsForTable(table: SqlTableReference, columnsByTable: Map<string, SqlCompletionColumn[]>, loadedColumnTables?: Set<string>): SqlCompletionColumn[] | null {
|
||||
export function oceanBaseDiagnosticTableReference(table: SqlTableReference, sql: string) {
|
||||
const range = sqlTextSpanToOffsetRange(sql, table.span);
|
||||
const identifiers = range ? tokenizeSqlSemantic(sql.slice(range.from, range.to), "oracle").filter(tokenIsIdentifier) : [];
|
||||
return { ...table, nameQuoted: !!identifiers[table.schema ? 1 : 0]?.quote, schemaQuoted: !!table.schema && !!identifiers[0]?.quote };
|
||||
}
|
||||
|
||||
function columnsForTable(table: SqlTableReference, columnsByTable: Map<string, SqlCompletionColumn[]>, loadedColumnTables?: Set<string>, currentSchema?: string): SqlCompletionColumn[] | null {
|
||||
const inlineColumns = (table as SqlTableReference & { columns?: string[] }).columns;
|
||||
if (inlineColumns && inlineColumns.length > 0) {
|
||||
return inlineColumns.map((name) => ({
|
||||
@@ -601,6 +609,8 @@ function columnsForTable(table: SqlTableReference, columnsByTable: Map<string, S
|
||||
schema: table.schema ?? undefined,
|
||||
}));
|
||||
}
|
||||
const scopedColumns = [...columnsByTable].filter(([key]) => matchesOceanBaseCompletionCacheKey(key, table, currentSchema));
|
||||
if (scopedColumns.length > 0) return scopedColumns.length === 1 ? scopedColumns[0]![1] : null;
|
||||
const keys = table.schema ? [table.database ? `${table.database}.${table.schema}.${table.name}` : undefined, `${table.schema}.${table.name}`, table.name].filter((key): key is string => !!key) : [table.name, ...keysWithTableName(columnsByTable, table.name)];
|
||||
for (const key of keys) {
|
||||
const normalizedKey = normalizeName(key);
|
||||
|
||||
@@ -1544,6 +1544,8 @@ export interface SqlCompletionContext {
|
||||
insertTable?: string;
|
||||
insertDatabase?: string;
|
||||
insertSchema?: string;
|
||||
insertTableQuoted?: boolean;
|
||||
insertSchemaQuoted?: boolean;
|
||||
statementKind: SqlStatementKind;
|
||||
tableTriggerWord?: string;
|
||||
isGroupBy: boolean;
|
||||
@@ -2668,6 +2670,8 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq
|
||||
insertTable: insertInfo?.table,
|
||||
insertDatabase: insertInfo?.database,
|
||||
insertSchema: insertInfo?.schema,
|
||||
insertTableQuoted: insertInfo?.nameQuoted,
|
||||
insertSchemaQuoted: insertInfo?.schemaQuoted,
|
||||
statementKind,
|
||||
tableTriggerWord: lastWord || undefined,
|
||||
isGroupBy: isInGroupByContext(beforeCursor),
|
||||
@@ -3252,7 +3256,7 @@ function detectComparisonLeftColumn(beforeCursor: string): string | undefined {
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function detectInsertColumnListContext(beforeCursor: string): { table: string; database?: string; schema?: string } | null {
|
||||
function detectInsertColumnListContext(beforeCursor: string): { table: string; database?: string; schema?: string; nameQuoted?: boolean; schemaQuoted?: boolean } | null {
|
||||
// Keep quoted identifiers intact so schema/table targets resolve to their
|
||||
// real names instead of placeholder string contents.
|
||||
const cleaned = beforeCursor.replace(/'[^']*'/g, "''");
|
||||
@@ -3263,12 +3267,15 @@ function detectInsertColumnListContext(beforeCursor: string): { table: string; d
|
||||
const fullTable = match[1];
|
||||
if (!fullTable) return null;
|
||||
const parts = splitQualifiedNameParts(fullTable);
|
||||
const rawParts = splitQualifiedNameRawParts(fullTable);
|
||||
const table = parts[parts.length - 1];
|
||||
if (!table) return null;
|
||||
return {
|
||||
table,
|
||||
database: parts.length >= 3 ? parts[parts.length - 3] : undefined,
|
||||
schema: parts.length >= 2 ? parts[parts.length - 2] : undefined,
|
||||
nameQuoted: isQuotedIdentifier(rawParts[rawParts.length - 1]),
|
||||
schemaQuoted: parts.length >= 2 ? isQuotedIdentifier(rawParts[rawParts.length - 2]) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5100,7 +5107,10 @@ function columnsForInsertTarget(context: SqlCompletionContext, columnsByTable: M
|
||||
const schemaKey = context.insertSchema ? normalizeIdentifierPart(context.insertSchema) : undefined;
|
||||
const databaseKey = context.insertDatabase ? normalizeIdentifierPart(context.insertDatabase) : undefined;
|
||||
const qualifiedKey = schemaKey ? normalizeCompletionKey(`${context.insertDatabase ? `${context.insertDatabase}.` : ""}${context.insertSchema}.${context.insertTable}`) : undefined;
|
||||
const reference = { name: context.insertTable, schema: context.insertSchema, nameQuoted: context.insertTableQuoted, schemaQuoted: context.insertSchemaQuoted };
|
||||
return collectCompletionColumns(columnsByTable).filter((column) => {
|
||||
const scopedMatch = matchesOceanBaseCompletionCacheKey(column.key, reference);
|
||||
if (scopedMatch !== undefined) return scopedMatch;
|
||||
if (normalizeIdentifierPart(column.table) !== tableKey) return false;
|
||||
if (!schemaKey) return true;
|
||||
if (!databaseKey && column.schema && normalizeIdentifierPart(column.schema) === schemaKey) return true;
|
||||
@@ -5235,6 +5245,8 @@ function referencedTableMatchesColumnQualifier(table: SqlCompletionReferencedTab
|
||||
}
|
||||
|
||||
function columnMatchesReferencedTable(column: SqlCompletionColumn & { key: string }, table: SqlCompletionReferencedTable): boolean {
|
||||
const scopedMatch = matchesOceanBaseCompletionCacheKey(column.key, table);
|
||||
if (scopedMatch !== undefined) return scopedMatch;
|
||||
if (normalizeIdentifierPart(column.table) !== normalizeIdentifierPart(table.name)) return false;
|
||||
if (!table.schema) return true;
|
||||
return columnMatchesQualifiedTable(column, { database: table.database, schema: table.schema, table: table.name });
|
||||
@@ -5309,7 +5321,26 @@ function buildJoinConditionItems(context: SqlCompletionContext, columnsByTable:
|
||||
return items;
|
||||
}
|
||||
|
||||
type CompletionCacheTableReference = { name: string; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean };
|
||||
|
||||
function oceanBaseCompletionReferenceKey(table: CompletionCacheTableReference): string {
|
||||
const schema = table.schema ? (table.schemaQuoted ? table.schema : table.schema.toUpperCase()) : null;
|
||||
const name = table.nameQuoted ? table.name : table.name.toUpperCase();
|
||||
return `oceanbase-oracle:${JSON.stringify([schema, name, !!table.schemaQuoted, !!table.nameQuoted])}:`;
|
||||
}
|
||||
|
||||
export function oceanBaseCompletionCacheKey(table: CompletionCacheTableReference, currentSchema?: string | null): string {
|
||||
return `${oceanBaseCompletionReferenceKey(table)}${JSON.stringify(currentSchema ?? null)}`;
|
||||
}
|
||||
|
||||
export function matchesOceanBaseCompletionCacheKey(key: string, table: CompletionCacheTableReference, currentSchema?: string): boolean | undefined {
|
||||
if (!key.startsWith("oceanbase-oracle:[")) return undefined;
|
||||
return currentSchema === undefined ? key.startsWith(oceanBaseCompletionReferenceKey(table)) : key === oceanBaseCompletionCacheKey(table, currentSchema);
|
||||
}
|
||||
|
||||
function columnsForReferencedTable(table: SqlCompletionReferencedTable, columnsByTable: Map<string, SqlCompletionColumn[]>): SqlCompletionColumn[] {
|
||||
const scopedColumns = [...columnsByTable].filter(([key]) => matchesOceanBaseCompletionCacheKey(key, table));
|
||||
if (scopedColumns.length > 0) return scopedColumns.length === 1 ? applyReferencedColumnAliases(table, scopedColumns[0]![1]) : [];
|
||||
const keys = table.schema ? [table.database ? `${table.database}.${table.schema}.${table.name}` : undefined, `${table.schema}.${table.name}`, table.name].filter((key): key is string => !!key) : [table.name];
|
||||
for (const key of keys) {
|
||||
const columns = columnsByTable.get(key);
|
||||
@@ -5320,6 +5351,8 @@ function columnsForReferencedTable(table: SqlCompletionReferencedTable, columnsB
|
||||
|
||||
function foreignKeysForReferencedTable(table: SqlCompletionReferencedTable, foreignKeysByTable?: Map<string, SqlCompletionForeignKey[]>): SqlCompletionForeignKey[] {
|
||||
if (!foreignKeysByTable) return [];
|
||||
const scopedKeys = [...foreignKeysByTable].filter(([key]) => matchesOceanBaseCompletionCacheKey(key, table));
|
||||
if (scopedKeys.length > 0) return scopedKeys.length === 1 ? scopedKeys[0]![1] : [];
|
||||
const keys = table.schema ? [table.database ? `${table.database}.${table.schema}.${table.name}` : undefined, `${table.schema}.${table.name}`, table.name].filter((key): key is string => !!key) : [table.name];
|
||||
for (const key of keys) {
|
||||
const foreignKeys = foreignKeysByTable.get(key);
|
||||
|
||||
@@ -784,6 +784,21 @@ describe("connectionStore completion assistant", () => {
|
||||
expect(store.lookupLocalCompletionColumns("oracle-1", "ORCL", "ORDERS")).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes OceanBase synonym lookup context separately and isolates selected schemas", async () => {
|
||||
const getColumns = vi.fn().mockResolvedValue([{ name: "ID", data_type: "NUMBER", is_nullable: false }]);
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({ getColumns, checkConnectionHealth: vi.fn().mockResolvedValue(undefined) }));
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.connections = [oceanBaseOracleConnection()];
|
||||
store.connectedIds.add("oceanbase-oracle-1");
|
||||
await store.listCompletionColumns("oceanbase-oracle-1", "OBORCL", "Alias", undefined, { currentSchema: "MixedOwner" });
|
||||
expect(getColumns).toHaveBeenLastCalledWith("oceanbase-oracle-1", "OBORCL", "", "Alias", undefined, undefined, "MixedOwner");
|
||||
await store.listCompletionColumns("oceanbase-oracle-1", "OBORCL", "Alias", undefined, { currentSchema: "OtherOwner" });
|
||||
expect(getColumns).toHaveBeenLastCalledWith("oceanbase-oracle-1", "OBORCL", "", "Alias", undefined, undefined, "OtherOwner");
|
||||
expect(getColumns).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("lets a JDBC connection to Oracle resolve CURRENT_SCHEMA for unqualified column completion, same as the native Oracle driver", async () => {
|
||||
const completionAssistantSearch = vi.fn().mockResolvedValue({
|
||||
candidates: [],
|
||||
|
||||
@@ -1341,6 +1341,61 @@ describe("queryStore hidden primary key editing", () => {
|
||||
expect(tab.result?.hidden_column_indexes).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ sourceSchema: undefined, synonym: true },
|
||||
{ sourceSchema: "ExplicitOwner", synonym: true },
|
||||
{ sourceSchema: undefined, synonym: false },
|
||||
])("keeps OceanBase current schema separate from explicit source schema $sourceSchema with synonym=$synonym", async ({ sourceSchema, synonym }) => {
|
||||
getConnectionConfig.mockReturnValue({ id: "ob-1", name: "OceanBase", db_type: "oceanbase-oracle", database: "service", query_timeout_secs: 30 });
|
||||
getColumns.mockResolvedValue([{ name: "ItemId", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: true, extra: null, ...(synonym ? { resolved_schema: "TargetOwner", resolved_table: "Items", resolved_object_type: "TABLE" } : {}) }]);
|
||||
analyzeEditableQueryEditability.mockResolvedValue({
|
||||
editable: true,
|
||||
analysis: {
|
||||
schema: sourceSchema,
|
||||
schemaQuoted: true,
|
||||
tableName: "ItemsAlias",
|
||||
tableNameQuoted: true,
|
||||
selectStar: false,
|
||||
columns: [{ sourceName: "ItemId", sourceNameQuoted: true, resultName: "ItemId", expression: '"ItemId"' }],
|
||||
},
|
||||
});
|
||||
executeMulti.mockResolvedValue([{ columns: ["ItemId"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("ob-1", "service", "Query", "query", "SelectedOwner");
|
||||
store.setAutoCommit(tabId, true);
|
||||
|
||||
await store.executeTabSql(tabId, sourceSchema ? 'SELECT "ItemId" FROM "ExplicitOwner"."ItemsAlias"' : 'SELECT "ItemId" FROM "ItemsAlias"');
|
||||
|
||||
if (sourceSchema) expect(getColumns).toHaveBeenCalledWith("ob-1", "service", "ExplicitOwner", "ItemsAlias", undefined);
|
||||
else expect(getColumns).toHaveBeenCalledWith("ob-1", "service", "", "ItemsAlias", undefined, undefined, "SelectedOwner");
|
||||
expect(listIndexes).toHaveBeenCalledWith("ob-1", "service", synonym ? "TargetOwner" : "SelectedOwner", synonym ? "Items" : "ItemsAlias", undefined);
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
await vi.waitFor(() => expect(tab.tableMeta).toMatchObject({ schema: synonym ? "TargetOwner" : "SelectedOwner", tableName: synonym ? "Items" : "ItemsAlias" }));
|
||||
if (synonym) expect(tab.tableMeta?.tableType).toBe("TABLE");
|
||||
expect(tab.queryAnalysis?.tableName).toBe("ItemsAlias");
|
||||
});
|
||||
|
||||
it.each(["VIEW", undefined])("does not treat a synonym target of type %s as an Oracle base table", async (objectType) => {
|
||||
getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 });
|
||||
getColumns.mockResolvedValue([{ name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: false, extra: null, resolved_schema: "TargetOwner", resolved_table: "ActualItems", resolved_object_type: objectType }]);
|
||||
lookupLocalCompletionTables.mockReturnValue([{ name: "ITEMS_ALIAS", type: "table", schema: "APP" }]);
|
||||
analyzeEditableQueryEditability.mockResolvedValue({ editable: true, analysis: { schema: "APP", tableName: "ITEMS_ALIAS", selectStar: true, columns: [] } });
|
||||
executeMulti.mockResolvedValue([{ columns: ["ID"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("oracle-1", "ORCL", "Query");
|
||||
store.setAutoCommit(tabId, true);
|
||||
|
||||
await store.executeTabSql(tabId, "SELECT * FROM APP.ITEMS_ALIAS");
|
||||
|
||||
expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", "SELECT * FROM APP.ITEMS_ALIAS", undefined, expect.any(String), expect.any(Object));
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
await vi.waitFor(() => expect(tab.result?.columns).toEqual(["ID"]));
|
||||
expect(tab.result?.hidden_column_indexes).toBeUndefined();
|
||||
expect(tab.queryAnalysis).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["CLOB", "XMLTYPE", "SYS.XMLTYPE"])("enables deferred Oracle %s values only when a base-table query has a stable key", async (dataType) => {
|
||||
getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 });
|
||||
getColumns.mockResolvedValue([
|
||||
|
||||
@@ -9260,7 +9260,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
/// the same fallback.
|
||||
const LOGIN_SCHEMA_COMPLETION_TYPES = new Set(["dameng", "db2"]);
|
||||
|
||||
async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise<SqlCompletionColumn[]> {
|
||||
async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean; currentSchema?: string }, catalog?: string): Promise<SqlCompletionColumn[]> {
|
||||
const config = getConfig(connectionId);
|
||||
// Use the effective database type (e.g. a JDBC connection whose URL is
|
||||
// `jdbc:oracle:...` resolves to "oracle") rather than the raw db_type.
|
||||
@@ -9282,7 +9282,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return [];
|
||||
}
|
||||
const sessionCacheScope = usesCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : "";
|
||||
const cacheKey = `${completionColumnsKey(connectionId, database, completionTable, completionSchema, catalog, context)}${sessionCacheScope}`;
|
||||
const currentSchema = effectiveDbType === "oceanbase-oracle" && usesCurrentSchema ? context?.currentSchema : undefined;
|
||||
const contextCacheScope = currentSchema ? `:current=${JSON.stringify(currentSchema)}` : "";
|
||||
const cacheKey = `${completionColumnsKey(connectionId, database, completionTable, completionSchema, catalog, context)}${sessionCacheScope}${contextCacheScope}`;
|
||||
if (!completionColumnsCache.value[cacheKey]) {
|
||||
const requestRevision = completionCacheRevision(connectionId, database);
|
||||
await withCompletionInFlight(
|
||||
@@ -9316,7 +9318,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
const querySchema = usesCurrentSchema ? "" : metadataQuerySchema(connectionId, database, completionSchema);
|
||||
const columns = await api.getColumns(connectionId, database, querySchema, completionTable, catalog, usesCurrentSchema ? context?.clientSessionId : undefined);
|
||||
const columns = currentSchema
|
||||
? await api.getColumns(connectionId, database, querySchema, completionTable, catalog, context?.clientSessionId, currentSchema)
|
||||
: await api.getColumns(connectionId, database, querySchema, completionTable, catalog, usesCurrentSchema ? context?.clientSessionId : undefined);
|
||||
if (requestRevision !== completionCacheRevision(connectionId, database)) return;
|
||||
completionColumnsCache.value[cacheKey] = columns;
|
||||
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
|
||||
@@ -9387,7 +9391,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return listCompletionDatabases(connectionId);
|
||||
}
|
||||
|
||||
function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise<SqlCompletionColumn[]> {
|
||||
function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean; currentSchema?: string }, catalog?: string): Promise<SqlCompletionColumn[]> {
|
||||
return listCompletionColumns(connectionId, database, table, schema, context, catalog);
|
||||
}
|
||||
|
||||
|
||||
@@ -6146,6 +6146,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
|
||||
function canUseQueryKeylessRowPredicate(databaseType: DatabaseType, loaded: LoadedEditableSource): boolean {
|
||||
if (loaded.tableMeta.columns.some((column) => column.resolved_table) && loaded.tableMeta.tableType?.trim().toUpperCase() !== "TABLE") return false;
|
||||
if (!canUseKeylessRowPredicate(databaseType, loaded.tableMeta.primaryKeys)) return false;
|
||||
// An unknown Oracle object may be a view whose query shape rejects ROWID
|
||||
// and whose rows cannot be mapped safely for writes. Keep the result
|
||||
@@ -6203,7 +6204,10 @@ export const useQueryStore = defineStore("query", () => {
|
||||
// (issue #10567). Fold the SQL-text schema, not a tab-selected one — the
|
||||
// object tree already reports the stored spelling.
|
||||
const foldedSourceSchema = foldUnquotedPostgresMetadataIdentifier(metadataDbType, source.schema, source.schemaQuoted);
|
||||
const schema = foldedSourceSchema || (dbType === "sqlserver" ? "" : tab.schema) || "";
|
||||
// An OceanBase public synonym is eligible only for an unqualified name.
|
||||
// Keep the selected CURRENT_SCHEMA separate from an explicit SQL owner.
|
||||
const resolveInCurrentOceanbaseSchema = metadataDbType === "oceanbase-oracle" && !source.schema;
|
||||
const schema = foldedSourceSchema || (dbType === "sqlserver" || resolveInCurrentOceanbaseSchema ? "" : tab.schema) || "";
|
||||
// Oracle-family connection databases are service names, not schemas. When
|
||||
// the query does not qualify a schema, let the driver resolve the current
|
||||
// login user's schema instead of looking up metadata under the service name.
|
||||
@@ -6235,7 +6239,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
// Keep SQL Server writes unqualified unless the SELECT source explicitly
|
||||
// named a schema, so SELECT and UPDATE resolve the same object.
|
||||
const writeSchema = dbType === "sqlserver" && !source.schema ? undefined : metadataSchema || undefined;
|
||||
const localTableType = oracleCompletionTableType(tab, metadataDbType, metadataDatabase, metadataSchema || conn?.default_schema || "", metadataTableName, metadataCatalog);
|
||||
const localTableType = oracleCompletionTableType(tab, metadataDbType, metadataDatabase, metadataSchema || (resolveInCurrentOceanbaseSchema ? tab.schema : undefined) || conn?.default_schema || "", metadataTableName, metadataCatalog);
|
||||
const knownTableType = localTableType ?? (tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined);
|
||||
return {
|
||||
source: metadataSource,
|
||||
@@ -6245,6 +6249,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
connectionId: tab.connectionId!,
|
||||
database: metadataDatabase,
|
||||
schema: metadataSchema,
|
||||
...(resolveInCurrentOceanbaseSchema && tab.schema ? { currentSchema: tab.schema } : {}),
|
||||
tableName: metadataTableName,
|
||||
tableType: knownTableType,
|
||||
databaseType: dbType,
|
||||
@@ -6256,7 +6261,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
|
||||
function loadedEditableSourceFromMetadata(target: EditableSourceMetadataTarget, metadata: Awaited<ReturnType<typeof loadTableMetadata>>["metadata"]): LoadedEditableSource {
|
||||
const usesReportedSchema = target.request.databaseType === "vastbase" || target.request.databaseType === "kingbase";
|
||||
const writeSchema = usesReportedSchema && !target.writeSchema ? metadata.schema : target.writeSchema;
|
||||
const resolvedTarget = metadata.columns.find((column) => column.resolved_table);
|
||||
const writeSchema = resolvedTarget?.resolved_schema ?? (usesReportedSchema && !target.writeSchema ? metadata.schema : target.writeSchema || target.request.currentSchema);
|
||||
return {
|
||||
source: target.source,
|
||||
analysis: target.analysis,
|
||||
@@ -6264,7 +6270,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
catalog: target.request.catalog,
|
||||
database: target.request.database,
|
||||
schema: writeSchema,
|
||||
tableName: target.request.tableName,
|
||||
tableName: resolvedTarget?.resolved_table ?? target.request.tableName,
|
||||
tableType: metadata.tableType,
|
||||
columns: metadata.columns,
|
||||
primaryKeys: metadata.primaryKeys,
|
||||
|
||||
@@ -1136,6 +1136,8 @@ export interface ColumnInfo {
|
||||
name: string;
|
||||
data_type: string;
|
||||
resolved_schema?: string;
|
||||
resolved_table?: string;
|
||||
resolved_object_type?: string;
|
||||
is_nullable: boolean;
|
||||
column_default: string | null;
|
||||
is_primary_key: boolean;
|
||||
|
||||
@@ -1434,6 +1434,8 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
@@ -2388,6 +2390,8 @@ mod tests {
|
||||
name: "large_val".to_string(),
|
||||
data_type: "BIGINT".to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
@@ -2406,6 +2410,8 @@ mod tests {
|
||||
name: "large_val".to_string(),
|
||||
data_type: "INT".to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
|
||||
@@ -7753,6 +7753,20 @@ pub async fn get_columns_core_for_session(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
client_session_id: Option<&str>,
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
get_columns_core_for_session_in_context(state, connection_id, database, schema, table, client_session_id, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Keep the selected OceanBase schema separate from an explicitly qualified object schema.
|
||||
pub async fn get_columns_core_for_session_in_context(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
client_session_id: Option<&str>,
|
||||
current_schema: Option<&str>,
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
if connection_config(state, connection_id).await.is_some_and(|config| config.db_type == DatabaseType::MongoDb) {
|
||||
return Box::pin(mongodb_columns::get_columns(state, connection_id, database, table)).await;
|
||||
@@ -7761,7 +7775,7 @@ pub async fn get_columns_core_for_session(
|
||||
let metadata_session =
|
||||
EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "columns").await;
|
||||
if metadata_session.client_session_id().is_some() {
|
||||
let result = get_columns_core_for_session_inner(
|
||||
let result = get_columns_core_for_session_inner_in_context(
|
||||
state,
|
||||
connection_id,
|
||||
database,
|
||||
@@ -7769,13 +7783,36 @@ pub async fn get_columns_core_for_session(
|
||||
table,
|
||||
metadata_session.client_session_id(),
|
||||
false,
|
||||
current_schema,
|
||||
)
|
||||
.await;
|
||||
metadata_session.finish(state, connection_id, Some(database)).await;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
get_columns_core_for_session_inner(state, connection_id, database, schema, table, client_session_id, true).await
|
||||
if current_schema.is_none() {
|
||||
return get_columns_core_for_session_inner(
|
||||
state,
|
||||
connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
client_session_id,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
get_columns_core_for_session_inner_in_context(
|
||||
state,
|
||||
connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
client_session_id,
|
||||
true,
|
||||
current_schema,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_columns_core_for_session_inner(
|
||||
@@ -7786,6 +7823,29 @@ async fn get_columns_core_for_session_inner(
|
||||
table: &str,
|
||||
client_session_id: Option<&str>,
|
||||
use_client_session_context: bool,
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
get_columns_core_for_session_inner_in_context(
|
||||
state,
|
||||
connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
client_session_id,
|
||||
use_client_session_context,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_columns_core_for_session_inner_in_context(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
client_session_id: Option<&str>,
|
||||
use_client_session_context: bool,
|
||||
current_schema: Option<&str>,
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
get_columns_core_for_session_inner_with_pool(
|
||||
state,
|
||||
@@ -7797,6 +7857,7 @@ async fn get_columns_core_for_session_inner(
|
||||
use_client_session_context,
|
||||
None,
|
||||
true,
|
||||
current_schema,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -7819,6 +7880,7 @@ async fn get_columns_core_for_existing_pool(
|
||||
true,
|
||||
Some(pool_key),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -7833,6 +7895,7 @@ async fn get_columns_core_for_session_inner_with_pool(
|
||||
use_client_session_context: bool,
|
||||
existing_pool_key: Option<&str>,
|
||||
allow_recovery: bool,
|
||||
current_schema: Option<&str>,
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
let context_session_id = if use_client_session_context { client_session_id } else { None };
|
||||
let existing_pool_key = existing_pool_key.map(str::to_owned);
|
||||
@@ -8001,10 +8064,13 @@ async fn get_columns_core_for_session_inner_with_pool(
|
||||
}
|
||||
}
|
||||
match client
|
||||
.get_columns::<Vec<db::ColumnInfo>>(
|
||||
.get_columns_in_context::<Vec<db::ColumnInfo>>(
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
current_schema.filter(|_| {
|
||||
db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OceanbaseOracle)
|
||||
}),
|
||||
agent_metadata_timeout(db_config.as_ref()),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -77,6 +77,8 @@ fn columns_from_sample(result: &MongoDocumentResult) -> Result<Vec<ColumnInfo>,
|
||||
name,
|
||||
data_type: types.into_iter().collect::<Vec<_>>().join(" | "),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_unique: false,
|
||||
|
||||
@@ -283,6 +283,8 @@ fn column_info_serialization_roundtrip() {
|
||||
name: "id".to_string(),
|
||||
data_type: "int".to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: true,
|
||||
@@ -331,6 +333,8 @@ fn table_columns_result_serialization_contract() {
|
||||
name: "id".to_string(),
|
||||
data_type: "int".to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: true,
|
||||
|
||||
@@ -18,6 +18,8 @@ fn col(name: &str, data_type: &str) -> ColumnInfo {
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
|
||||
@@ -21,6 +21,8 @@ fn col(name: &str, data_type: &str) -> ColumnInfo {
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
|
||||
@@ -36,6 +36,8 @@ fn generate_details(tables: &[TableInfo], columns_per_table: usize) -> Vec<Table
|
||||
name: format!("col_{}", j),
|
||||
data_type: if j % 3 == 0 { "int".to_string() } else { "varchar(64)".to_string() },
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: j % 2 == 0,
|
||||
column_default: if j == 0 { Some("0".to_string()) } else { None },
|
||||
is_primary_key: j == 0,
|
||||
|
||||
@@ -27,6 +27,8 @@ fn col(name: &str, data_type: &str) -> ColumnInfo {
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
|
||||
@@ -20,6 +20,8 @@ fn column(name: &str, data_type: &str) -> ColumnInfo {
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
|
||||
@@ -2373,12 +2373,22 @@ impl AgentDriverClient {
|
||||
table: &str,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<T, String> {
|
||||
self.call_method_with_timeout(
|
||||
AgentMethod::GetColumns,
|
||||
agent_schema_table_params(database, schema, table),
|
||||
timeout_duration,
|
||||
)
|
||||
.await
|
||||
self.get_columns_in_context(database, schema, table, None, timeout_duration).await
|
||||
}
|
||||
|
||||
pub async fn get_columns_in_context<T: DeserializeOwned + Send + 'static>(
|
||||
&mut self,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
current_schema: Option<&str>,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<T, String> {
|
||||
let mut params = agent_schema_table_params(database, schema, table);
|
||||
if let Some(current_schema) = current_schema {
|
||||
params["current_schema"] = serde_json::json!(current_schema);
|
||||
}
|
||||
self.call_method_with_timeout(AgentMethod::GetColumns, params, timeout_duration).await
|
||||
}
|
||||
|
||||
pub async fn get_custom_type_details<T: DeserializeOwned + Send + 'static>(
|
||||
@@ -4617,6 +4627,9 @@ def respond(req):
|
||||
|
||||
session_id = params.get('agentSessionId', '__legacy__')
|
||||
with session_lock(session_id):
|
||||
if method == 'get_columns':
|
||||
write_response(req, params)
|
||||
return
|
||||
if method in ('execute_query', 'start_table_read'):
|
||||
sql = params.get('sql', '')
|
||||
with state_lock:
|
||||
@@ -4664,6 +4677,43 @@ for line in sys.stdin:
|
||||
runtime.call(method, serde_json::json!({}), Some(Duration::from_secs(2)), None).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn columns_rpc_preserves_explicit_schema_and_optional_current_schema() {
|
||||
let (runtime, script_path) = spawn_stateful_test_runtime("columns-context-test").await;
|
||||
let mut client = AgentDriverClient::shared_session(runtime.clone(), "columns-session".to_string());
|
||||
|
||||
let contextual: serde_json::Value = client
|
||||
.get_columns_in_context("APP", "", "ORDERS_ALIAS", Some("SelectedOwner"), Some(Duration::from_secs(2)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(contextual["schema"], "");
|
||||
assert_eq!(contextual["current_schema"], "SelectedOwner");
|
||||
assert_eq!(contextual["table"], "ORDERS_ALIAS");
|
||||
assert_eq!(contextual["agentSessionId"], "columns-session");
|
||||
|
||||
let qualified: serde_json::Value = client
|
||||
.get_columns_in_context(
|
||||
"APP",
|
||||
"ActualOwner",
|
||||
"ORDERS_ALIAS",
|
||||
Some("SelectedOwner"),
|
||||
Some(Duration::from_secs(2)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(qualified["schema"], "ActualOwner");
|
||||
assert_eq!(qualified["current_schema"], "SelectedOwner");
|
||||
|
||||
let legacy: serde_json::Value =
|
||||
client.get_columns("APP", "OWNER", "ORDERS_ALIAS", Some(Duration::from_secs(2))).await.unwrap();
|
||||
assert_eq!(legacy["schema"], "OWNER");
|
||||
assert!(legacy.get("current_schema").is_none());
|
||||
|
||||
runtime.kill();
|
||||
wait_for_runtime_reap(&runtime).await;
|
||||
let _ = std::fs::remove_file(script_path);
|
||||
}
|
||||
|
||||
async fn wait_for_runtime_reap(runtime: &AgentRuntimeClient) {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !runtime.child_reaped.load(Ordering::Acquire) {
|
||||
|
||||
@@ -4944,6 +4944,8 @@ where
|
||||
name,
|
||||
data_type: column_type,
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: get_str_by_name(row, "IS_NULLABLE") == "YES",
|
||||
column_default: get_opt_str(row, "COLUMN_DEFAULT"),
|
||||
extra: get_opt_str(row, "EXTRA"),
|
||||
@@ -5017,6 +5019,8 @@ async fn fetch_columns_show(
|
||||
name,
|
||||
data_type: get_str_by_name(row, "Type"),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"),
|
||||
column_default: get_opt_str(row, "Default"),
|
||||
is_primary_key: key.eq_ignore_ascii_case("PRI"),
|
||||
@@ -8802,6 +8806,8 @@ mod tests {
|
||||
name: " state value".to_string(),
|
||||
data_type: "unknown".to_string(),
|
||||
resolved_schema: Some("analytics".to_string()),
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: true,
|
||||
column_default: Some("seed".to_string()),
|
||||
is_primary_key: true,
|
||||
|
||||
@@ -201,6 +201,8 @@ pub async fn get_columns_show_from(
|
||||
name,
|
||||
data_type: get_str_by_name(row, "Type"),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"),
|
||||
column_default: get_opt_str(row, "Default"),
|
||||
is_primary_key: key.eq_ignore_ascii_case("PRI"),
|
||||
|
||||
@@ -384,6 +384,8 @@ pub async fn get_columns(client: &MeilisearchClient, index: &str) -> Result<Vec<
|
||||
name,
|
||||
data_type: field_type.label().to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_unique: false,
|
||||
|
||||
@@ -865,6 +865,8 @@ fn parse_describe_columns(describe: &Value) -> Vec<ColumnInfo> {
|
||||
character_maximum_length: length.and_then(|l| i32::try_from(l).ok()),
|
||||
enum_values: picklist_values,
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
// Plugin-provided column metadata capabilities do not apply:
|
||||
// the describe payload is parsed here, not by a plugin driver.
|
||||
metadata_capabilities: None,
|
||||
|
||||
@@ -2807,6 +2807,8 @@ fn infer_document_columns(documents: &[Value]) -> Vec<ColumnInfo> {
|
||||
name,
|
||||
data_type,
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
|
||||
@@ -6908,6 +6908,8 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
@@ -9957,6 +9959,8 @@ mod tests {
|
||||
name: "status".to_string(),
|
||||
data_type: "text".to_string(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
@@ -13175,6 +13179,8 @@ mod tests {
|
||||
name: "c".into(),
|
||||
data_type: "varchar(100)".into(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: true,
|
||||
column_default: Some("'default'".into()),
|
||||
comment: Some("new".into()),
|
||||
@@ -13193,6 +13199,8 @@ mod tests {
|
||||
name: "c".into(),
|
||||
data_type: "varchar(50)".into(),
|
||||
resolved_schema: None,
|
||||
resolved_table: None,
|
||||
resolved_object_type: None,
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
comment: Some("old".into()),
|
||||
|
||||
@@ -398,18 +398,20 @@ pub async fn get_columns(
|
||||
table: String,
|
||||
catalog: Option<String>,
|
||||
client_session_id: Option<String>,
|
||||
current_schema: Option<String>,
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await {
|
||||
return dbx_core::schema::get_doris_catalog_columns_core(&state, &connection_id, &catalog, &database, &table)
|
||||
.await;
|
||||
}
|
||||
dbx_core::schema::get_columns_core_for_session(
|
||||
dbx_core::schema::get_columns_core_for_session_in_context(
|
||||
&state,
|
||||
&connection_id,
|
||||
&database,
|
||||
&schema,
|
||||
&table,
|
||||
client_session_id.as_deref(),
|
||||
current_schema.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -291,6 +291,10 @@ pub struct ColumnInfo {
|
||||
pub data_type: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_schema: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_table: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_object_type: Option<String>,
|
||||
pub is_nullable: bool,
|
||||
pub column_default: Option<String>,
|
||||
pub is_primary_key: bool,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use dbx_types::types::ColumnInfo;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn column_metadata_preserves_synonym_target_identity_across_rpc() {
|
||||
let payload = json!({
|
||||
"name": "Amount", "data_type": "NUMBER(12,2)", "is_nullable": true,
|
||||
"column_default": null, "is_primary_key": false, "extra": null,
|
||||
"comment": null, "numeric_precision": 12, "numeric_scale": 2,
|
||||
"character_maximum_length": null,
|
||||
"resolved_schema": "MixedOwner", "resolved_table": "OrderView",
|
||||
"resolved_object_type": "VIEW"
|
||||
});
|
||||
let column: ColumnInfo = serde_json::from_value(payload.clone()).unwrap();
|
||||
let forwarded = serde_json::to_value(column).unwrap();
|
||||
for key in ["resolved_schema", "resolved_table", "resolved_object_type"] {
|
||||
assert_eq!(forwarded.get(key), payload.get(key), "lost {key} in Core metadata forwarding");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_column_metadata_does_not_invent_a_target_type() {
|
||||
let payload = json!({
|
||||
"name": "ID", "data_type": "NUMBER", "is_nullable": false,
|
||||
"column_default": null, "is_primary_key": true, "extra": null,
|
||||
"comment": null, "numeric_precision": null, "numeric_scale": null,
|
||||
"character_maximum_length": null
|
||||
});
|
||||
let column: ColumnInfo = serde_json::from_value(payload).unwrap();
|
||||
let forwarded = serde_json::to_value(column).unwrap();
|
||||
assert!(forwarded.get("resolved_table").is_none());
|
||||
assert!(forwarded.get("resolved_object_type").is_none());
|
||||
}
|
||||
@@ -27,6 +27,7 @@ pub struct SchemaQuery {
|
||||
pub table_name_filter: Option<String>,
|
||||
pub apply_visible_filter: Option<bool>,
|
||||
pub client_session_id: Option<String>,
|
||||
pub current_schema: Option<String>,
|
||||
pub include_postgres_access: Option<bool>,
|
||||
pub portable: Option<bool>,
|
||||
pub execution_id: Option<String>,
|
||||
@@ -484,8 +485,8 @@ where
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn should_cache_columns(client_session_id: Option<&str>) -> bool {
|
||||
client_session_id.is_none()
|
||||
fn should_cache_columns(client_session_id: Option<&str>, current_schema: Option<&str>) -> bool {
|
||||
client_session_id.is_none() && current_schema.is_none()
|
||||
}
|
||||
|
||||
pub async fn get_plugin_table_metadata(
|
||||
@@ -511,19 +512,20 @@ pub async fn list_columns(
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
} else {
|
||||
dbx_core::schema::get_columns_core_for_session(
|
||||
dbx_core::schema::get_columns_core_for_session_in_context(
|
||||
&state.app,
|
||||
&q.connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
q.client_session_id.as_deref(),
|
||||
q.current_schema.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
};
|
||||
let result = if should_cache_columns(q.client_session_id.as_deref()) {
|
||||
let result = if should_cache_columns(q.client_session_id.as_deref(), q.current_schema.as_deref()) {
|
||||
cached_metadata(
|
||||
&state.app,
|
||||
&q.connection_id,
|
||||
@@ -979,9 +981,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn session_scoped_columns_bypass_persistent_cache() {
|
||||
assert!(should_cache_columns(None));
|
||||
assert!(!should_cache_columns(Some("oracle-session")));
|
||||
assert!(!should_cache_columns(Some("")));
|
||||
assert!(should_cache_columns(None, None));
|
||||
assert!(!should_cache_columns(Some("oracle-session"), None));
|
||||
assert!(!should_cache_columns(Some(""), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_schema_columns_bypass_cache_without_changing_explicit_schema() {
|
||||
let query: SchemaQuery = serde_json::from_value(serde_json::json!({
|
||||
"connection_id": "ob", "database": "LOGIN", "schema": "", "table": "ORDERS_ALIAS",
|
||||
"current_schema": "SelectedOwner"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(query.schema.as_deref(), Some(""));
|
||||
assert_eq!(query.current_schema.as_deref(), Some("SelectedOwner"));
|
||||
assert!(!should_cache_columns(query.client_session_id.as_deref(), query.current_schema.as_deref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user