fix(oceanbase): preserve object paging and completion metadata identity

This commit is contained in:
efeire
2026-10-12 12:04:08 +08:00
committed by GitHub
parent a88f8dabe7
commit 380f6a548b
92 changed files with 4056 additions and 311 deletions
@@ -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
+ ")";
}
}
@@ -9,6 +9,8 @@ public final class CompletionAssistantCandidate {
private final String parent_name;
private final String comment;
private final String data_type;
private final String signature;
private final String routine_id;
public CompletionAssistantCandidate(
String name,
@@ -19,6 +21,21 @@ public final class CompletionAssistantCandidate {
String parentName,
String comment,
String dataType
) {
this(name, kind, database, schema, parentSchema, parentName, comment, dataType, null, null);
}
public CompletionAssistantCandidate(
String name,
CompletionAssistantCandidateKind kind,
String database,
String schema,
String parentSchema,
String parentName,
String comment,
String dataType,
String signature,
String routineId
) {
this.name = name;
this.kind = kind;
@@ -28,6 +45,8 @@ public final class CompletionAssistantCandidate {
this.parent_name = parentName;
this.comment = comment;
this.data_type = dataType;
this.signature = signature;
this.routine_id = routineId;
}
public String getName() { return name; }
@@ -38,4 +57,6 @@ public final class CompletionAssistantCandidate {
public String getParent_name() { return parent_name; }
public String getComment() { return comment; }
public String getData_type() { return data_type; }
public String getSignature() { return signature; }
public String getRoutine_id() { return routine_id; }
}
@@ -6,11 +6,17 @@ public final class CompletionAssistantResponse {
private final List<CompletionAssistantCandidate> candidates;
private final boolean incomplete;
private final boolean fallback_used;
private final Boolean routine_search_supported;
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed) {
this(candidates, incomplete, fallbackUsed, null);
}
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed, Boolean routineSearchSupported) {
this.candidates = candidates;
this.incomplete = incomplete;
this.fallback_used = fallbackUsed;
this.routine_search_supported = routineSearchSupported;
}
public List<CompletionAssistantCandidate> getCandidates() { return candidates; }
@@ -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);
@@ -262,7 +262,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
String.format(baseSql, placeholders(objectTypes.size())),
"OBJECT_NAME, TABLE_TYPE, COMMENTS",
"o.OBJECT_NAME",
"ORDER BY OBJECT_NAME",
"ORDER BY OBJECT_NAME, o.OBJECT_ID",
owner,
objectTypes,
constraints
@@ -319,7 +319,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
WHEN 'PACKAGE BODY' THEN 5
WHEN 'SEQUENCE' THEN 6
ELSE 7
END, OBJECT_NAME
END, OBJECT_NAME, o.OBJECT_ID
""".stripIndent().trim(),
owner,
objectTypes,
@@ -327,17 +327,13 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
);
List<ObjectInfo> result = new ArrayList<>();
String sql = query.sql;
if (constraints.hasLimit() || constraints.hasOffset()) {
sql += "\nORDER BY DBX_RN";
}
try (var stmt = requireConnection().prepareStatement(sql)) {
try (var stmt = requireConnection().prepareStatement(query.sql)) {
bind(stmt, query.args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String objectType = rs.getString(2);
result.add(new ObjectInfo(rs.getString(1),
"PACKAGE BODY".equals(objectType) ? "PACKAGE_BODY" : objectType, owner, rs.getString(3)));
objectType.replace(' ', '_'), owner, rs.getString(3)));
}
}
}
@@ -350,9 +346,196 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
if (hasTableLikeCompletionKind(request.getObject_kinds())) {
return unchecked(() -> completionAssistantTables(request));
}
if (request.getObject_kinds().stream().anyMatch(kind -> kind == CompletionAssistantObjectKind.ROUTINE
|| kind == CompletionAssistantObjectKind.PROCEDURE || kind == CompletionAssistantObjectKind.FUNCTION
|| kind == CompletionAssistantObjectKind.SEQUENCE)) {
return unchecked(() -> completionAssistantRoutines(request));
}
return super.completionAssistantSearch(request);
}
private CompletionAssistantResponse completionAssistantRoutines(CompletionAssistantRequest request) throws SQLException {
if (firstNonBlank(request.getParent_name()) != null) {
return completionAssistantPackageMembers(request);
}
int limit = boundedCompletionLimit(request.getMax_results());
String preferredSchema = preferredCompletionSchema(request);
List<CompletionAssistantObjectKind> kinds = request.getObject_kinds();
List<Object> args = new ArrayList<>();
String types = kinds.contains(CompletionAssistantObjectKind.ROUTINE)
|| (kinds.contains(CompletionAssistantObjectKind.PROCEDURE) && kinds.contains(CompletionAssistantObjectKind.FUNCTION))
? "'PROCEDURE', 'FUNCTION'"
: kinds.contains(CompletionAssistantObjectKind.PROCEDURE) ? "'PROCEDURE'"
: kinds.contains(CompletionAssistantObjectKind.FUNCTION) ? "'FUNCTION'" : "";
if (kinds.contains(CompletionAssistantObjectKind.SEQUENCE)) types += types.isEmpty() ? "'SEQUENCE'" : ", 'SEQUENCE'";
String pattern = completionLikePattern(request.getMask(), request.getMatch_mode());
args.add(request.getCase_sensitive() ? pattern : pattern.toUpperCase(Locale.ROOT));
String ownerFilter = "";
if (!request.getGlobal_search()) {
ownerFilter = " AND OWNER = ?";
args.add(firstNonBlank(request.getParent_schema(), request.getSchema(), preferredSchema));
}
args.add(preferredSchema);
args.add(limit + 1);
String sql = """
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE FROM (
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE FROM ALL_OBJECTS
WHERE OBJECT_TYPE IN (%s) AND %s%s
ORDER BY CASE WHEN OWNER = ? THEN 0 ELSE 1 END, OBJECT_NAME, OWNER, OBJECT_TYPE
) WHERE ROWNUM <= ?
""".formatted(types, completionNamePredicate("OBJECT_NAME", request.getCase_sensitive()), ownerFilter);
List<CompletionAssistantCandidate> candidates = new ArrayList<>();
boolean incomplete = false;
try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
bindCompletionArgs(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
if (candidates.size() == limit) {
incomplete = true;
break;
}
String objectType = rs.getString(3);
candidates.add(new CompletionAssistantCandidate(rs.getString(2),
"PROCEDURE".equals(objectType) ? CompletionAssistantCandidateKind.PROCEDURE
: "SEQUENCE".equals(objectType) ? CompletionAssistantCandidateKind.SEQUENCE : CompletionAssistantCandidateKind.FUNCTION,
blankToNull(request.getDatabase()), rs.getString(1), null, null, null, objectType));
}
}
}
return new CompletionAssistantResponse(candidates, incomplete, false, true);
}
private CompletionAssistantResponse completionAssistantPackageMembers(CompletionAssistantRequest request) throws SQLException {
String owner = firstNonBlank(request.getParent_schema(), request.getSchema(), preferredCompletionSchema(request));
String packageName = request.getParent_name();
String objectId;
try (PreparedStatement stmt = requireConnection().prepareStatement(
"SELECT OBJECT_ID FROM ALL_OBJECTS WHERE OWNER = ? AND OBJECT_NAME = ? AND OBJECT_TYPE = 'PACKAGE'")) {
bindCompletionArgs(stmt, List.of(owner, packageName));
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) throw new SQLException("Package is not visible: " + owner + "." + packageName);
objectId = rs.getString(1);
}
}
int limit = boundedCompletionLimit(request.getMax_results());
String pattern = completionLikePattern(request.getMask(), request.getMatch_mode());
List<Object> args = new ArrayList<>(List.of(owner, packageName, objectId,
request.getCase_sensitive() ? pattern : pattern.toUpperCase(Locale.ROOT)));
String kindFilter = request.getObject_kinds().contains(CompletionAssistantObjectKind.ROUTINE)
|| (request.getObject_kinds().contains(CompletionAssistantObjectKind.PROCEDURE)
&& request.getObject_kinds().contains(CompletionAssistantObjectKind.FUNCTION))
? "" : request.getObject_kinds().contains(CompletionAssistantObjectKind.FUNCTION)
? " WHERE ROUTINE_KIND = 'FUNCTION'" : " WHERE ROUTINE_KIND = 'PROCEDURE'";
args.add(limit + 1);
// Limit subprograms before loading arguments, so the last signature cannot be cut in half.
String sql = """
SELECT PROCEDURE_NAME, SUBPROGRAM_ID, ROUTINE_KIND FROM (
SELECT * FROM (
SELECT p.PROCEDURE_NAME, p.SUBPROGRAM_ID,
CASE WHEN EXISTS (SELECT 1 FROM ALL_ARGUMENTS a
WHERE a.OWNER = p.OWNER AND a.OBJECT_ID = p.OBJECT_ID
AND a.SUBPROGRAM_ID = p.SUBPROGRAM_ID AND a.DATA_LEVEL = 0 AND a.POSITION = 0)
THEN 'FUNCTION' ELSE 'PROCEDURE' END ROUTINE_KIND
FROM ALL_PROCEDURES p
WHERE p.OWNER = ? AND p.OBJECT_NAME = ? AND p.OBJECT_ID = ?
AND p.OBJECT_TYPE = 'PACKAGE' AND p.PROCEDURE_NAME IS NOT NULL AND %s
)%s ORDER BY PROCEDURE_NAME, SUBPROGRAM_ID
) WHERE ROWNUM <= ?
""".formatted(completionNamePredicate("p.PROCEDURE_NAME", request.getCase_sensitive()), kindFilter);
List<PackageCompletionMember> members = new ArrayList<>();
boolean incomplete = false;
try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
bindCompletionArgs(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
if (members.size() == limit) { incomplete = true; break; }
members.add(new PackageCompletionMember(rs.getString(1), rs.getString(2), "FUNCTION".equals(rs.getString(3))));
}
}
}
if (!members.isEmpty()) {
String placeholders = String.join(",", Collections.nCopies(members.size(), "?"));
String argumentSql = """
SELECT a.SUBPROGRAM_ID, a.POSITION, a.ARGUMENT_NAME, a.IN_OUT, a.DATA_TYPE,
a.TYPE_OWNER, a.TYPE_NAME, a.TYPE_SUBNAME, a.DEFAULTED, type_owner_object.OWNER AS TYPE_SCHEMA
FROM ALL_ARGUMENTS a
LEFT JOIN ALL_OBJECTS type_owner_object
ON type_owner_object.OBJECT_TYPE = 'DATABASE'
AND TO_CHAR(type_owner_object.OBJECT_ID) = TRIM(a.TYPE_OWNER)
WHERE a.OWNER = ? AND a.OBJECT_ID = ? AND a.DATA_LEVEL = 0 AND a.SUBPROGRAM_ID IN (%s)
ORDER BY a.SUBPROGRAM_ID, a.POSITION, a.SEQUENCE
""".formatted(placeholders);
List<Object> argumentArgs = new ArrayList<>(List.of(owner, objectId));
Map<String, PackageCompletionMember> byId = new LinkedHashMap<>();
for (PackageCompletionMember member : members) {
argumentArgs.add(member.id);
byId.put(member.id, member);
}
try (PreparedStatement stmt = requireConnection().prepareStatement(argumentSql)) {
bindCompletionArgs(stmt, argumentArgs);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
PackageCompletionMember member = byId.get(rs.getString(1));
if (member == null) continue;
String typeName = rs.getString(7);
String typeOwner = rs.getString(6);
// OB can expose a padded database object ID instead of the schema name.
if (typeOwner != null && typeOwner.matches("[0-9]+ +")) typeOwner = rs.getString(10);
if (typeName != null && typeOwner == null) {
member.signatureKnown = false;
continue;
}
String type = typeName == null ? rs.getString(5)
: java.util.stream.Stream.of(typeOwner, typeName, rs.getString(8))
.filter(value -> value != null && !value.isBlank())
.map(OceanBaseOracleAgent::quotePackageCompletionIdentifier)
.collect(java.util.stream.Collectors.joining("."));
if (rs.getInt(2) == 0) {
member.returnType = type;
continue;
}
String name = rs.getString(3);
String direction = rs.getString(4);
if (name == null || type == null || direction == null) {
member.signatureKnown = false;
continue;
}
member.parameters.add(quotePackageCompletionIdentifier(name) + " " + direction + " " + type
+ ("Y".equals(rs.getString(9)) ? " DEFAULT" : ""));
}
}
}
}
List<CompletionAssistantCandidate> candidates = new ArrayList<>();
for (PackageCompletionMember member : members) {
String identity = owner.length() + ":" + owner + ":" + objectId + ":" + member.id;
candidates.add(new CompletionAssistantCandidate(member.name,
member.function ? CompletionAssistantCandidateKind.FUNCTION : CompletionAssistantCandidateKind.PROCEDURE,
blankToNull(request.getDatabase()), owner, owner, packageName, null, member.returnType,
member.signatureKnown ? String.join(", ", member.parameters) : null, identity));
}
return new CompletionAssistantResponse(candidates, incomplete, false, true);
}
private static String quotePackageCompletionIdentifier(String identifier) {
return identifier.matches("[A-Z][A-Z0-9_$#]*") ? identifier : "\"" + identifier.replace("\"", "\"\"") + "\"";
}
private static final class PackageCompletionMember {
final String name;
final String id;
final boolean function;
final List<String> parameters = new ArrayList<>();
boolean signatureKnown = true;
String returnType;
PackageCompletionMember(String name, String id, boolean function) {
this.name = name;
this.id = id;
this.function = function;
}
}
private CompletionAssistantResponse completionAssistantTables(CompletionAssistantRequest request) throws SQLException {
int limit = boundedCompletionLimit(request.getMax_results());
int scanLimit = Math.min(1000, Math.max(limit * 3, limit + 1));
@@ -696,7 +879,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
sql = "SELECT " + selectList + "\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n"
+ sql
+ "\n ) DBX_Q\n WHERE ROWNUM <= ?\n)\nWHERE DBX_RN > ?";
args.add(offset + constraints.getLimit());
args.add((long) offset + constraints.getLimit());
args.add(offset);
} else if (constraints.hasOffset()) {
sql = "SELECT " + selectList + "\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n"
@@ -704,6 +887,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
+ "\n ) DBX_Q\n)\nWHERE DBX_RN > ?";
args.add(constraints.getOffset());
}
if (constraints.hasLimit() || constraints.hasOffset()) {
sql += "\nORDER BY DBX_RN";
}
return new MetadataSql(sql, args);
}
@@ -722,7 +908,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
}
private static List<String> oceanBaseObjectTypes(MetadataListConstraints constraints) {
List<String> supported = List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE BODY", "SEQUENCE", "SYNONYM");
List<String> supported = List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY");
if (!constraints.hasObjectTypes()) {
return supported;
}
@@ -895,6 +1081,8 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
Object arg = args.get(index);
if (arg instanceof Integer) {
stmt.setInt(index + 1, (Integer) arg);
} else if (arg instanceof Long) {
stmt.setLong(index + 1, (Long) arg);
} else {
stmt.setString(index + 1, String.valueOf(arg));
}
@@ -913,6 +1101,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);
@@ -1,6 +1,7 @@
package com.dbx.agent.oceanbaseoracle;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.CompletionAssistantCandidateKind;
import com.dbx.agent.CompletionAssistantMatchMode;
import com.dbx.agent.CompletionAssistantObjectKind;
import com.dbx.agent.CompletionAssistantRequest;
@@ -485,6 +486,220 @@ class OceanBaseOracleAgentTest {
Assertions.assertEquals(List.of("ORD%", "STAGING", "ORD%", "STAGING", "DWD", "ORD", 21), query.args);
}
@Test
void completionAssistantSearchReturnsBoundedStandaloneRoutines() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
new Object[][]{{"APP", "CALC", "PROCEDURE"}, {"APP", "CALCULATE", "FUNCTION"}, {"OTHER", "CALCULATE", "FUNCTION"}}
)));
CompletionAssistantRequest request = completionRequest("APP", null, "CAL", true);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "max_results", 2);
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertEquals(List.of("CALC", "CALCULATE"), response.getCandidates().stream().map(candidate -> candidate.getName()).toList());
Assertions.assertEquals(List.of("procedure", "function"), response.getCandidates().stream().map(candidate -> candidate.getKind().name().toLowerCase(Locale.ROOT)).toList());
Assertions.assertEquals("APP", response.getCandidates().get(0).getSchema());
Assertions.assertNull(response.getCandidates().get(0).getParent_name());
Assertions.assertTrue(response.getIncomplete());
Assertions.assertFalse(response.getFallback_used());
Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?"));
Assertions.assertTrue(new com.google.gson.Gson().toJsonTree(response).getAsJsonObject().has("routine_search_supported"));
}
@Test
void mixedRoutineSearchPreservesRequestedSequences() {
for (CompletionAssistantObjectKind routineKind : List.of(CompletionAssistantObjectKind.ROUTINE, CompletionAssistantObjectKind.FUNCTION)) {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
new Object[][]{{"APP", "ORDER_SEQ", "SEQUENCE"}}
)));
CompletionAssistantRequest request = completionRequest("APP", null, "ORDER", false);
setField(request, "object_kinds", List.of(routineKind, CompletionAssistantObjectKind.SEQUENCE));
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertTrue(sql.get(0).contains("'SEQUENCE'"));
Assertions.assertEquals(CompletionAssistantCandidateKind.SEQUENCE, response.getCandidates().get(0).getKind());
}
}
@Test
void sequenceOnlySearchUsesOceanBaseFilteringWithoutAddingFunctions() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
new Object[][]{{"APP", "ORDER_SEQ", "SEQUENCE"}}
)));
CompletionAssistantRequest request = completionRequest("APP", null, "ORDER", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.SEQUENCE));
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertEquals(1, response.getCandidates().size());
Assertions.assertEquals(CompletionAssistantCandidateKind.SEQUENCE, response.getCandidates().get(0).getKind());
Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE IN ('SEQUENCE')"));
Assertions.assertFalse(sql.get(0).contains("'FUNCTION'"));
Assertions.assertFalse(response.getFallback_used());
}
@Test
void routineSearchPreservesQuotedOwnersAndEscapesLiteralMasks() {
List<String> sql = new ArrayList<>();
List<String> params = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, params, resultSet(
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
new Object[][]{{"Mixed.Owner", "中文_%", "PROCEDURE"}}
)));
CompletionAssistantRequest request = completionRequest("APP", "Mixed.Owner", "中文_%", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.PROCEDURE));
setField(request, "case_sensitive", true);
setField(request, "match_mode", CompletionAssistantMatchMode.CONTAINS);
setField(request, "max_results", 1);
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertEquals(List.of("%中文\\_\\%%", "Mixed.Owner", "APP"), params);
Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE IN ('PROCEDURE')"));
Assertions.assertTrue(sql.get(0).contains("OWNER = ?"));
Assertions.assertFalse(sql.get(0).contains("UPPER(OBJECT_NAME)"));
Assertions.assertEquals("中文_%", response.getCandidates().get(0).getName());
Assertions.assertFalse(response.getIncomplete(), "Exactly the requested count is complete");
}
@Test
void routineSearchDoesNotReturnStandaloneCandidatesForAPackageRequest() {
CompletionAssistantRequest request = completionRequest("APP", null, "P", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertThrows(RuntimeException.class, () -> new OceanBaseOracleAgent().completionAssistantSearch(request));
}
@Test
void packageCompletionKeepsNoArgumentMembersAndBoundedOverloadsWithCompleteSignatures() {
List<String> sql = new ArrayList<>();
List<String> params = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, params,
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"},
new Object[][]{{"RUN", 1, "PROCEDURE"}, {"RUN", 2, "FUNCTION"}, {"RUN", 3, "FUNCTION"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED"},
new Object[][]{
{2, 0, null, "OUT", "NUMBER", null, null, null, "N"},
{2, 1, "INPUT", "IN", "NUMBER", null, null, null, "Y"},
{2, 2, "RESULT", "IN/OUT", "PL/SQL RECORD", "Mixed.Owner", "Types", "Payload", "N"}
})
));
CompletionAssistantRequest request = completionRequest("APP", "Mixed.Owner", "Ru_%", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "Mixed.Package");
setField(request, "case_sensitive", true);
setField(request, "max_results", 2);
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertTrue(response.getIncomplete());
Assertions.assertEquals(2, response.getCandidates().size());
var procedure = response.getCandidates().get(0);
var function = response.getCandidates().get(1);
Assertions.assertEquals("", procedure.getSignature());
Assertions.assertEquals("INPUT IN NUMBER DEFAULT, RESULT IN/OUT \"Mixed.Owner\".\"Types\".\"Payload\"", function.getSignature());
Assertions.assertEquals("NUMBER", function.getData_type());
Assertions.assertEquals("Mixed.Owner", function.getParent_schema());
Assertions.assertEquals("Mixed.Package", function.getParent_name());
Assertions.assertNotEquals(procedure.getRoutine_id(), function.getRoutine_id());
Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE = 'PACKAGE'"));
Assertions.assertTrue(sql.get(1).contains("p.OBJECT_ID = ?"));
Assertions.assertTrue(sql.get(1).contains("ROWNUM <= ?"));
Assertions.assertTrue(sql.get(2).contains("DATA_LEVEL = 0"));
Assertions.assertTrue(sql.get(2).contains("SUBPROGRAM_ID IN (?,?)"));
Assertions.assertFalse(sql.get(2).contains("ROWNUM"));
Assertions.assertTrue(params.contains("Ru\\_\\%%"));
var wire = new com.google.gson.Gson().toJsonTree(response).getAsJsonObject().getAsJsonArray("candidates");
Assertions.assertEquals("", wire.get(0).getAsJsonObject().get("signature").getAsString());
Assertions.assertEquals(function.getRoutine_id(), wire.get(1).getAsJsonObject().get("routine_id").getAsString());
}
@Test
void packageCompletionResolvesInternalTypeOwnerThroughDatabaseIdentity() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql,
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"OBJ", 6, "PROCEDURE"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED", "TYPE_SCHEMA"},
new Object[][]{{6, 1, "X", "IN", "EXT", "514099 ", "Base.Type", null, "N", " Actual.Owner "}})
));
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertEquals("X IN \" Actual.Owner \".\"Base.Type\"", agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
Assertions.assertTrue(sql.get(2).contains("type_owner_object.OBJECT_TYPE = 'DATABASE'"));
Assertions.assertTrue(sql.get(2).contains("TO_CHAR(type_owner_object.OBJECT_ID) = TRIM(a.TYPE_OWNER)"));
}
@Test
void packageCompletionKeepsLiteralNumericTypeOwner() {
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"OBJ", 6, "PROCEDURE"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED"},
new Object[][]{{6, 1, "X", "IN", "EXT", "514099", "Base.Type", null, "N"}})
));
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertEquals("X IN \"514099\".\"Base.Type\"", agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
}
@Test
void packageCompletionDoesNotInventUnresolvedInternalTypeOwner() {
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"OBJ", 6, "PROCEDURE"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED", "TYPE_SCHEMA"},
new Object[][]{{6, 1, "X", "IN", "EXT", "514099 ", "Base.Type", null, "N", null}})
));
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertNull(agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
}
@Test
void packageCompletionDoesNotInventMissingParameterMetadata() {
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"RUN", 1, "PROCEDURE"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED"},
new Object[][]{{1, 1, "P", "IN", null, null, null, null, "N"}})
));
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertNull(agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
}
@Test
void packageCompletionDistinguishesVisibleEmptyPackageFromInvisiblePackage() {
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{})));
Assertions.assertTrue(agent.completionAssistantSearch(request).getCandidates().isEmpty());
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{})));
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> agent.completionAssistantSearch(request));
Assertions.assertTrue(error.toString().contains("Package is not visible"));
}
@Test
void completionAssistantSearchReturnsGlobalTableCandidates() {
List<String> sql = new ArrayList<>();
@@ -57,7 +57,7 @@ class OceanBaseOracleObjectListTest {
case "_" -> "%\\_%";
default -> "%\\\\%";
};
assertEquals(List.of("Mixed.Owner", "TABLE", pattern, pattern, 3, 2), call.args);
assertEquals(List.of("Mixed.Owner", "TABLE", pattern, pattern, 3L, 2), call.args);
call.assertClosed();
}
@@ -78,13 +78,13 @@ class OceanBaseOracleObjectListTest {
};
assertEquals(List.of("APP", "TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE",
"PACKAGE BODY", "SEQUENCE", "SYNONYM"), call.args);
"PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY"), call.args);
assertEquals(List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE",
"PACKAGE_BODY", "SEQUENCE", "SYNONYM"), objects.stream().map(ObjectInfo::getObject_type).toList());
assertEquals(8, objects.size());
assertTrue(objects.stream().allMatch(object -> "APP".equals(object.getSchema())));
assertTrue(objects.stream().allMatch(object -> object.getValid() == null));
assertTrue(call.sql.contains("OBJECT_TYPE IN (?, ?, ?, ?, ?, ?, ?, ?)"), call.sql);
assertTrue(call.sql.contains("OBJECT_TYPE IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), call.sql);
assertFalse(call.sql.contains("ROWNUM"), call.sql);
assertObjectOrder(call.sql);
call.assertClosed();
@@ -134,6 +134,17 @@ class OceanBaseOracleObjectListTest {
call.assertClosed();
}
@Test
void typeFilterRequestsSpecificationsAndBodiesBeforePaging() {
JdbcFixture jdbc = new JdbcFixture();
JdbcCall call = jdbc.rows(row("CUSTOM_TYPE", "TYPE"), row("CUSTOM_TYPE", "TYPE BODY"));
assertEquals(List.of(new ObjectInfo("CUSTOM_TYPE", "TYPE", "APP", null),
new ObjectInfo("CUSTOM_TYPE", "TYPE_BODY", "APP", null)),
jdbc.agent.listObjects("APP", constraints(null, 3, 0, "TYPE", "TYPE_BODY")));
assertEquals(List.of("APP", "TYPE", "TYPE BODY", 3L, 0), call.args);
call.assertClosed();
}
@Test
void filteredPagesKeepSameNameSpecAndBodyAndDoNotApplyOffsetTwice() {
JdbcFixture jdbc = new JdbcFixture();
@@ -143,7 +154,7 @@ class OceanBaseOracleObjectListTest {
pages.addAll(jdbc.agent.listObjects("APP",
constraints("k_'%", 1, offset, "PACKAGE", "PACKAGE_BODY")));
assertEquals(List.of("APP", "PACKAGE", "PACKAGE BODY", "%K%\\_%'%\\%%", "%K%\\_%'%\\%%", offset + 1, offset), call.args);
assertEquals(List.of("APP", "PACKAGE", "PACKAGE BODY", "%K%\\_%'%\\%%", "%K%\\_%'%\\%%", offset + 1L, offset), call.args);
assertTrue(call.sql.contains("UPPER(OBJECT_NAME) LIKE ? ESCAPE '\\'"), call.sql);
assertTrue(call.sql.contains("ROWNUM <= ?"), call.sql);
assertTrue(call.sql.contains("WHERE DBX_RN > ?"), call.sql);
@@ -169,6 +180,27 @@ class OceanBaseOracleObjectListTest {
call.assertClosed();
}
@Test
void tablePagesKeepTheOuterOrder() {
JdbcFixture jdbc = new JdbcFixture();
JdbcCall call = jdbc.rows(row("Z_LATE", "TABLE", "订单"));
assertEquals("Z_LATE", jdbc.agent.listTables("APP", constraints("订单", 1, 2, "TABLE")).get(0).getName());
assertTrue(call.sql.endsWith("ORDER BY DBX_RN"), call.sql);
assertTrue(call.sql.contains("ORDER BY OBJECT_NAME, o.OBJECT_ID"), call.sql);
call.assertClosed();
}
@Test
void pageUpperBoundDoesNotOverflowTheProtocolIntegerRange() {
JdbcFixture jdbc = new JdbcFixture();
JdbcCall call = jdbc.rows();
assertEquals(List.of(), jdbc.agent.listObjects("APP", constraints(null, 2, Integer.MAX_VALUE, "TABLE")));
assertEquals(List.of("APP", "TABLE", 2147483649L, Integer.MAX_VALUE), call.args);
call.assertClosed();
}
@ParameterizedTest
@ValueSource(strings = {"TABLE", "VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM"})
void unrelatedTypeRequestsKeepTheirParametersAndMapping(String type) {
@@ -185,7 +217,7 @@ class OceanBaseOracleObjectListTest {
void unsupportedTypesDoNotQueryTheDatabase() {
JdbcFixture jdbc = new JdbcFixture();
assertEquals(List.of(), jdbc.agent.listObjects("APP", constraints(null, null, null, "TRIGGER", "TYPE_BODY")));
assertEquals(List.of(), jdbc.agent.listObjects("APP", constraints(null, null, null, "TRIGGER", "EVENT")));
assertEquals(0, jdbc.prepared);
}
@@ -318,7 +350,7 @@ class OceanBaseOracleObjectListTest {
for (int rank = 0; rank < types.size(); rank++) {
assertTrue(sql.contains("WHEN '" + types.get(rank) + "' THEN " + rank), sql);
}
assertTrue(sql.contains("ELSE 7\nEND, OBJECT_NAME"), sql);
assertTrue(sql.contains("ELSE 7\nEND, OBJECT_NAME, o.OBJECT_ID"), sql);
}
private static final class JdbcFixture {
@@ -363,7 +395,7 @@ class OceanBaseOracleObjectListTest {
PreparedStatement statement() {
return proxy(PreparedStatement.class, (statement, method, values) -> switch (method.getName()) {
case "setString", "setInt", "setObject" -> {
case "setString", "setInt", "setLong", "setObject" -> {
assertEquals(args.size() + 1, values[0]);
args.add(values[1]);
yield null;
@@ -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;
}
}
@@ -350,6 +350,7 @@ async function loadStructurePeekColumns(panelId: string, requestId: number, targ
try {
const objectMetadataRequest = {
connectionId: props.connectionId,
queryTimeoutSecs: connectionStore.getConfig(props.connectionId)?.query_timeout_secs,
database,
schema,
tableName: target.name,
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { CompletionContext, insertCompletionText, snippetCompletion } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { createPinia, setActivePinia } from "pinia";
import { computed, reactive, shallowRef } from "vue";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useQueryEditorCompletion } from "../useQueryEditorCompletion";
import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata";
import type { QueryEditorProps } from "../queryEditorTypes";
import type { CompletionAssistantRequest, ConnectionConfig } from "@/types/database";
const api = vi.hoisted(() => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
completionAssistantSearch: vi.fn(),
listCompletionObjects: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => api);
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
type Options = Parameters<typeof useQueryEditorCompletion>[0];
const cleanups: Array<() => void> = [];
beforeEach(() => {
setActivePinia(createPinia());
api.completionAssistantSearch.mockReset().mockResolvedValue({ candidates: [], incomplete: false, fallback_used: true });
api.listCompletionObjects.mockReset().mockResolvedValue([{ name: "RUN", schema: "APP", object_type: "PROCEDURE" }]);
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
});
function createEditor(sql = "CALL APP.R") {
const connectionStore = useConnectionStore();
connectionStore.connections = [{ id: "legacy-ob", name: "OB", db_type: "oceanbase-oracle", database: "OB", username: "CURRENT", transport_layers: [] } as unknown as ConnectionConfig];
connectionStore.connectedIds.add("legacy-ob");
const props = reactive<QueryEditorProps>({ connectionId: "legacy-ob", database: "OB", schema: "CURRENT", databaseType: "oceanbase-oracle", dialect: "oracle", modelValue: sql });
const parent = document.createElement("div");
document.body.append(parent);
const currentView = new EditorView({ parent, state: EditorState.create({ doc: sql, selection: { anchor: sql.length } }) });
const view = shallowRef<EditorView | null>(currentView);
const metadata = useQueryEditorCompletionMetadata({ props, view, connectionStore, sqlBehaviorDialect: () => props.dialect, remoteLatencyBudgetMs: 100, maxCompletionTables: 100, onDemandMinPrefix: 2, semanticCompletionEnabled: false });
const completion = useQueryEditorCompletion({
props,
view,
connectionStore,
settingsStore: useSettingsStore(),
sqlDriverProfile: computed(() => undefined),
t: ((key: string) => key) as Options["t"],
metadata,
runtime: { codeMirrorInsertCompletionText: insertCompletionText, codeMirrorSnippetCompletion: snippetCompletion, codeMirrorCompletionStatus: () => null, imeCompositionActive: false },
batchSelection: {
clearBatchColumnSelectionSession: () => undefined,
prepareBatchColumnSelectionSession: () => null,
isBatchColumnSelectionAction: () => false,
batchColumnSelectionMarkerForItem: () => undefined,
cacheBatchColumnSelectionOption: (_marker: unknown, option: unknown) => option,
} as unknown as Options["batchSelection"],
isEditorComposing: () => false,
debounceDelayMs: 1,
triggerDeferDelayMs: 1,
maxCompletionTables: 100,
onDemandTableLimit: 100,
semanticCompletionEnabled: false,
});
cleanups.push(() => {
completion.invalidateRequests();
currentView.destroy();
parent.remove();
});
return () => completion.provideSqlCompletions(new CompletionContext(currentView.state, sql.length, true));
}
it.each([null, "OceanBase agent does not support filtered routine completion; update the agent", "Unknown method: completion_assistant_search", "Method not found"])("retains schema routines from the real Store fallback when the old Agent cannot search packages: %s", async (error) => {
if (error)
api.completionAssistantSearch.mockImplementation(async (request: CompletionAssistantRequest) => {
if (request.object_kinds.includes("procedure")) throw new Error(error);
return { candidates: [], incomplete: false, fallback_used: false };
});
const provide = createEditor();
const result = await provide();
expect(result?.options.some((option) => option.label === "RUN")).toBe(true);
expect(api.listCompletionObjects).toHaveBeenCalledWith("legacy-ob", "OB", "APP");
expect(api.completionAssistantSearch.mock.calls.some(([request]) => request.parent_name === "APP")).toBe(true);
});
it("does not fabricate package members from the old Agent's standalone fallback", async () => {
const result = await createEditor("CALL CURRENT.PKG.R")();
expect(result?.options.some((option) => option.label === "RUN") ?? false).toBe(false);
expect(api.listCompletionObjects).not.toHaveBeenCalled();
});
it.each(["ORA-01031: insufficient privileges", "Network connection lost", "Network transport not supported", "Request timed out"])("does not hide a failed package probe: %s", async (message) => {
api.completionAssistantSearch.mockImplementation(async (request: CompletionAssistantRequest) => {
if (request.parent_name) throw new Error(message);
return { candidates: [{ name: "RUN", schema: "APP", kind: "procedure" }], incomplete: false, fallback_used: false };
});
expect(await createEditor()()).toBeNull();
});
it("does not convert two failed interpretations into successful completion", async () => {
api.completionAssistantSearch.mockRejectedValue(new Error("Network connection lost"));
expect(await createEditor()()).toBeNull();
});
@@ -9,7 +9,7 @@ import { useQueryEditorCompletion } from "../useQueryEditorCompletion";
import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata";
import type { QueryEditorProps } from "../queryEditorTypes";
import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs";
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import type { SqlCompletionColumn, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis";
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
@@ -41,19 +41,19 @@ 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(() => []),
lookupLocalCompletionObjects: vi.fn((): SqlCompletionObject[] => []),
lookupLocalCompletionDatabases: vi.fn((): string[] => []),
lookupLocalCompletionSchemas: vi.fn((): string[] => []),
lookupLocalCompletionForeignKeys: vi.fn(() => []),
listCompletionTables: vi.fn(async (): Promise<SqlCompletionTable[]> => []),
listCompletionObjects: vi.fn(async () => []),
listCompletionObjects: vi.fn(async (): Promise<SqlCompletionObject[]> => []),
listCompletionColumns: vi.fn(async (): Promise<SqlCompletionColumn[]> => []),
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,105 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
return { props, currentView, view, store, settings, runtime, completion, provide, startCompletion };
}
describe.each([false, true])("OceanBase standalone routine completion (semantic=%s)", (semanticCompletionEnabled) => {
beforeEach(() => vi.useRealTimers());
it("resolves an unselected package schema remotely without using another owner's cached members", async () => {
const { provide, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", modelValue: "CALL PKG.R" }, undefined, semanticCompletionEnabled);
store.lookupLocalCompletionObjects.mockReturnValue([{ name: "RUN_OTHER", schema: "OTHER", parentSchema: "OTHER", parentName: "PKG", type: "procedure", signature: "P NUMBER" }]);
store.listCompletionObjects.mockImplementation(async (...args: unknown[]) => (args[5] === "PKG" ? [{ name: "RUN_APP", schema: "APP", parentSchema: "APP", parentName: "PKG", type: "procedure", signature: "P DATE" }] : []));
const local = await provide(false);
expect(local?.options.some((option) => option.label === "RUN_OTHER") ?? false).toBe(false);
const remote = await provide();
expect(remote?.options.some((option) => option.label === "RUN_APP")).toBe(true);
expect(remote?.options.some((option) => option.label === "RUN_OTHER")).toBe(false);
});
it("loads a quoted owner exactly and inserts a case-sensitive routine identifier", async () => {
const sql = 'CALL "Mixed.Owner".';
const { provide, store, currentView, settings } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", database: "OB", schema: "APP", modelValue: sql }, undefined, semanticCompletionEnabled);
settings.editorSettings.generateSqlQuoteIdentifiers = true;
store.listCompletionObjects.mockResolvedValue([{ name: "Do.Work", schema: "Mixed.Owner", type: "procedure", signature: "" }]);
const result = await provide();
expect(store.listCompletionObjects).toHaveBeenCalledWith("connection", "OB", "", 100, "Mixed.Owner", undefined, false, "APP", expect.any(Array), false);
const option = result!.options.find((candidate) => candidate.label === "Do.Work")!;
expect(option).toBeDefined();
if (typeof option.apply === "function") option.apply(currentView, option, result!.from, currentView.state.doc.length);
else currentView.dispatch(insertCompletionText(currentView.state, option.apply ?? option.label, result!.from, currentView.state.doc.length));
expect(currentView.state.doc.toString()).toBe('CALL "Mixed.Owner"."Do.Work"()');
});
it.each([
["CALL pkg.", "APP", "PKG", 'CALL pkg."Do.Work"()'],
['CALL "Mixed.Owner"."Mixed.Package".', "Mixed.Owner", "Mixed.Package", 'CALL "Mixed.Owner"."Mixed.Package"."Do.Work"()'],
])("loads package members for %s and inserts only the member", async (sql, owner, packageName, expected) => {
const { provide, store, currentView, settings } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", database: "OB", schema: "APP", modelValue: sql }, undefined, semanticCompletionEnabled);
settings.editorSettings.generateSqlQuoteIdentifiers = true;
store.listCompletionObjects.mockImplementation(async (...args: unknown[]) => (args[5] === packageName ? [{ name: "Do.Work", schema: owner, parentSchema: owner, parentName: packageName, type: "procedure", signature: "", routineId: "101:1" }] : []));
const result = await provide();
expect(store.listCompletionObjects).toHaveBeenCalledWith("connection", "OB", "", 100, owner, packageName, false, "APP", expect.any(Array), false);
const option = result!.options.find((candidate) => candidate.label === "Do.Work")!;
expect(option).toBeDefined();
if (typeof option.apply === "function") option.apply(currentView, option, result!.from, currentView.state.doc.length);
else currentView.dispatch(insertCompletionText(currentView.state, option.apply ?? option.label, result!.from, currentView.state.doc.length));
expect(currentView.state.doc.toString()).toBe(expected);
});
it("keeps unknown overloads separate and does not invent empty argument lists", async () => {
const { provide, store, currentView } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", database: "OB", schema: "APP", modelValue: "CALL APP.PKG." }, undefined, semanticCompletionEnabled);
store.listCompletionObjects.mockResolvedValue([1, 2].map((id) => ({ name: "RUN", schema: "APP", parentSchema: "APP", parentName: "PKG", type: "procedure", routineId: `101:${id}` })));
const result = await provide();
const options = result!.options.filter((candidate) => candidate.label === "RUN");
expect(options).toHaveLength(2);
const option = options[0]!;
if (typeof option.apply === "function") option.apply(currentView, option, result!.from, currentView.state.doc.length);
else currentView.dispatch(insertCompletionText(currentView.state, option.apply ?? option.label, result!.from, currentView.state.doc.length));
expect(currentView.state.doc.toString()).toBe("CALL APP.PKG.RUN");
});
it("drops a routine response when the request is invalidated", async () => {
const { provide, store, completion } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", database: "OB", schema: "APP", modelValue: "CALL P" }, undefined, semanticCompletionEnabled);
let resolve!: (objects: SqlCompletionObject[]) => void;
store.listCompletionObjects.mockReturnValue(
new Promise((done) => {
resolve = done;
}),
);
const pending = provide();
await vi.waitFor(() => expect(store.listCompletionObjects).toHaveBeenCalled());
completion.invalidateRequests();
resolve([{ name: "P_OLD", schema: "APP", type: "procedure" }]);
const result = await pending;
expect(result?.options.some((option) => option.label === "P_OLD") ?? false).toBe(false);
});
});
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());
@@ -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 });
@@ -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, metadata, unmount };
}
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("maps a typed execution error onto a selected statement in a larger document", () => {
const statement = "SELECT missing;";
const modelValue = `SELECT 1;\n${statement}`;
@@ -1109,11 +1109,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);
}
}
@@ -1271,8 +1273,10 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
}
if (!localOnlyMetadata && shouldLoadCompletionObjects(completionContext)) {
const completionObjectScope = routineCompletionScopeForContext(completionContext, scope);
const refreshEpoch = completionEpoch;
void listCompletionObjectsForContext(completionContext, scope)
.then((objects) => {
if (refreshEpoch !== completionEpoch) return;
const cachedObjects = completionObjectsForScope(completionObjectScope);
const merged = mergeCompletionObjects(cachedObjects, objects);
const changed = completionObjectsDiffer(cachedObjects, merged);
@@ -1292,12 +1296,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(() => {});
}
@@ -1354,7 +1357,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
* extra package-style queries entirely.
*/
function usesPackageAwareRoutineCompletion(): boolean {
if (props.databaseType === "oracle") return true;
if (props.databaseType === "oracle" || props.databaseType === "oceanbase-oracle") return true;
if (props.databaseType !== "opengauss") return false;
const mode = connectionStore.databaseCompatibilityMode(props.connectionId, props.database)?.trim().toUpperCase();
return mode === undefined || mode === "A";
@@ -1375,12 +1378,18 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
}
function routineCompletionTargetForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope) {
return resolveSqlCompletionRoutineLookupTarget({
const target = resolveSqlCompletionRoutineLookupTarget({
currentDatabase: scope.database,
currentSchema: scope.schema,
supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(),
completionContext,
});
if (props.databaseType === "oceanbase-oracle" && completionContext.qualifier) {
const parts = completionContext.qualifierParts ?? completionContext.qualifier.split(".");
const index = parts.length - 1;
target.schema = completionContext.qualifierQuoted?.[index] ? parts[index]!.replaceAll('""', '"') : parts[index]!.toUpperCase();
}
return target;
}
function routineCompletionScopeForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope): CompletionMetadataScope {
@@ -1391,7 +1400,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
function lookupLocalCompletionObjectsForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope): SqlCompletionObject[] {
if (!props.connectionId || props.database == null) return [];
if (usesPackageAwareRoutineCompletion()) {
if (usesPackageAwareRoutineCompletion() || (props.databaseType === "oceanbase-oracle" && !completionContext.qualifier)) {
return connectionStore.lookupLocalCompletionObjects(props.connectionId, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES);
}
const target = routineCompletionTargetForContext(completionContext, scope);
@@ -1401,6 +1410,23 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
async function listCompletionObjectsForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope): Promise<SqlCompletionObject[]> {
if (!props.connectionId || props.database == null) return [];
const objectKinds = completionObjectKindsForContext(completionContext);
if (props.databaseType === "oceanbase-oracle") {
const parts = (completionContext.qualifierParts ?? completionContext.qualifier?.split(".") ?? []).map((part, index) => (completionContext.qualifierQuoted?.[index] ? part.replaceAll('""', '"') : part.toUpperCase()));
const list = (schema: string | undefined, parentName?: string, globalSearch = false) =>
connectionStore.listCompletionObjects(props.connectionId!, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES, schema, parentName, globalSearch, scope.schema, objectKinds, !!completionContext.prefixQuoted);
if (parts.length === 0) return list(scope.schema, undefined, true);
if (parts.length === 2) return list(parts[0], parts[1]);
if (parts.length > 2) return [];
// One qualifier can name a schema or a package in the current schema.
const groups = await Promise.allSettled([list(parts[0]), list(scope.schema, parts[0])]);
if (groups[0].status === "rejected" && groups[1].status === "rejected") throw groups[0].reason;
let objects: SqlCompletionObject[] = [];
for (const group of groups) {
if (group.status === "fulfilled") objects = mergeCompletionObjects(objects, group.value);
else if (!/Package is not visible:/.test(String(group.reason)) && !/^(?:Error: )?(?:OceanBase agent does not support filtered routine completion(?:; update the agent)?|(?:unknown method|method not found)(?::?\s+completion_assistant_search)?)$/i.test(String(group.reason))) throw group.reason;
}
return objects;
}
if (!usesPackageAwareRoutineCompletion()) {
const target = routineCompletionTargetForContext(completionContext, scope);
return connectionStore.listCompletionObjects(props.connectionId, target.database, target.mask, MAX_COMPLETION_TABLES, target.schema, undefined, false, scope.schema, objectKinds);
@@ -1423,6 +1449,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();
@@ -1430,12 +1465,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 {
@@ -1487,6 +1522,21 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
let completionObjects = shouldLoadObjects ? (localOnlyMetadata ? lookupLocalCompletionObjectsForContext(completionContext, scope) : await listCompletionObjectsForContext(completionContext, scope)) : scopedCachedCompletionObjects;
if (epoch !== completionEpoch) return null;
// With no selected schema, only the targeted server lookup can resolve a
// one-part package qualifier. The shared local index spans other owners.
let resolvedRoutineSchema = scope.schema;
const qualifierParts = completionContext.qualifierParts ?? completionContext.qualifier?.split(".") ?? [];
if (props.databaseType === "oceanbase-oracle" && !resolvedRoutineSchema && shouldLoadObjects && !localOnlyMetadata && qualifierParts.length === 1) {
const packageName = completionContext.qualifierQuoted?.[0] ? qualifierParts[0]!.replaceAll('""', '"') : qualifierParts[0]!.toUpperCase();
const owners = new Set(
completionObjects
.filter((object) => object.parentName === packageName)
.map((object) => object.parentSchema ?? object.schema)
.filter(Boolean),
);
if (owners.size === 1) resolvedRoutineSchema = owners.values().next().value;
}
if (!props.catalog && !isOracleCompletionDatabase(props.databaseType) && !localOnlyMetadata && completionContext.qualifier && completionObjects.length === 0) {
const target = routineCompletionTargetForContext(completionContext, scope);
const schemaObjects = await connectionStore.listCompletionObjects(props.connectionId!, target.database, target.mask, MAX_COMPLETION_TABLES, target.schema, undefined, false, scope.schema);
@@ -1686,7 +1736,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
dialect: props.dialect,
databaseType: snippetDatabaseType.value,
driverProfile: sqlDriverProfile.value,
currentSchema: scope.schema,
currentSchema: resolvedRoutineSchema,
keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase,
functionCase: settingsStore.editorSettings.sqlFormatter.functionCase,
identifierCase: settingsStore.editorSettings.sqlFormatter.identifierCase,
@@ -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) => {
@@ -571,7 +573,8 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
}
function completionObjectScopeKey(scope: CompletionMetadataScope): string {
return `${scope.database}:${scope.schema ?? ""}`.toLowerCase();
const key = JSON.stringify([scope.database, scope.schema]);
return props.databaseType === "oceanbase-oracle" ? key : key.toLowerCase();
}
function completionObjectsForScope(scope: CompletionMetadataScope): SqlCompletionObject[] {
@@ -579,7 +582,8 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
}
function completionObjectIdentityKey(object: SqlCompletionObject): string {
return `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}:${object.signature?.trim() ?? ""}`.toLowerCase();
const key = JSON.stringify([object.type, object.schema, object.name, object.parentName, object.routineId ?? object.signature?.trim()]);
return props.databaseType === "oceanbase-oracle" ? key : key.toLowerCase();
}
function completionObjectsDiffer(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]): boolean {
@@ -28,6 +28,7 @@ import {
buildSqlSemanticDiagnostics,
isSqlSemanticDiagnosticInputContext,
isSqlVirtualTableReference,
oceanBaseDiagnosticTableReference,
shouldRunSqlSemanticDiagnostics,
sqlSemanticDiagnosticRangesForViewport,
sqlServerRoutineDefinitionRangesForViewport,
@@ -270,7 +271,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 }));
}
@@ -285,7 +287,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;
}
@@ -459,7 +461,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);
@@ -479,6 +481,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
loadedColumnTables: metadata.loadedColumnsByTable,
sql: range.sql,
databaseType: props.databaseType,
currentSchema: metadataScope.schema,
}),
range,
sql,
@@ -593,7 +596,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;
@@ -614,6 +617,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
loadedColumnTables: metadata.loadedColumnsByTable,
sql: range.sql,
databaseType: props.databaseType,
currentSchema: metadataScope.schema,
},
),
range,
@@ -213,6 +213,7 @@ export function useQueryEditorHover(options: QueryEditorHoverOptions) {
const hoverQualifiedName = [hoverScope.catalog, hoverDatabase, hoverSchema, table.name].filter(Boolean).join(".");
const objectMetadataRequest = {
connectionId: props.connectionId,
queryTimeoutSecs: connectionStore.getConfig(props.connectionId)?.query_timeout_secs,
database: hoverDatabase,
schema: hoverSchema,
tableName: table.name,
@@ -1321,6 +1321,7 @@ async function loadActiveDdlViewer(force: boolean) {
connectionId: tab.connectionId,
database: tab.database,
schema: ddlViewer.schema || tab.database,
queryTimeoutSecs: (connectionStore.getConfig(tab.connectionId) ?? props.activeConnection)?.query_timeout_secs,
tableName: ddlViewer.tableName,
objectType: ddlViewer.objectType,
catalog: tab.catalog,
@@ -12,6 +12,7 @@ import { matchesShortcut } from "@/lib/editor/keyboardShortcuts";
import { selectLineEndsDefaultShortcut, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
import { selectLineEnds } from "@/lib/editor/selectLineEnds";
import { useSettingsStore } from "@/stores/settingsStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
@@ -56,6 +57,7 @@ const { t } = useI18n();
const { toast } = useToast();
const { isDark, themePalette } = useTheme();
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
const originalDdlContent = ref("");
const formattedDdlContent = ref("");
@@ -296,6 +298,7 @@ async function loadDdl(force = false) {
const { ddl } = await loadObjectDdl(
{
connectionId: props.connectionId,
queryTimeoutSecs: connectionStore.getConfig(props.connectionId)?.query_timeout_secs,
database: props.database,
schema,
tableName: props.tableName,
@@ -136,6 +136,7 @@ import MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { applyDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
@@ -167,7 +168,7 @@ import {
} from "@/lib/table/objectBrowserRows";
import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleClick, singleClickRowAction, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction";
import { objectBrowserTableSelectionAnchor, objectBrowserTableSelectionRange } from "@/lib/table/objectBrowserSelection";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { customTypeCapabilities, supportsTypeObjectSource, type SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
import { visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
@@ -176,6 +177,7 @@ import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPr
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
import { cacheObjectBrowserRows, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRowsForScaffold, type ObjectBrowserRowsCacheScope, type ObjectBrowserRowsCacheWriteToken } from "@/lib/table/objectBrowserRowsCache";
import { createObjectBrowserRowsLoadGuard, type ObjectBrowserRowsLoadHandle } from "@/lib/table/objectBrowserRowsLoadGuard";
import { metadataLoadBudgetMs } from "@/lib/metadata/metadataLoadBudget";
import { loadObjectDdl, type ObjectDdlRequest } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache";
@@ -231,12 +233,16 @@ const selectedSchema = ref<string | undefined>(props.schema);
const rows = ref<ObjectBrowserRow[]>([]);
const rootRef = ref<HTMLElement>();
const search = ref(props.initialSearchQuery ?? "");
const objectFilter = ref<ObjectFilter>("all");
const objectFilter = ref<ObjectFilter>(props.connection.db_type === "oceanbase-oracle" ? (props.selectedObjectFilter ?? props.initialObjectFilter ?? "tables") : "all");
const userHasSelectedFilter = ref(false);
const sortKey = ref<ObjectBrowserSortKey>("name");
const sortDirection = ref<ObjectBrowserSortDirection>("asc");
const loadingSchemas = ref(false);
const loadingObjects = ref(false);
const loadingMoreObjects = ref(false);
const hasMoreObjects = ref(false);
let objectPageOffset = 0;
let objectSearchTimer: ReturnType<typeof setTimeout> | undefined;
const refreshingObjects = ref(false);
const scaffoldRefreshError = ref("");
const sourceLoading = ref(false);
@@ -319,6 +325,8 @@ const sidePanelGuard = createSidePanelRequestGuard();
const sidePanelRef = ref<InstanceType<typeof CustomTypeInfoPanel> | null>(null);
const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => getTableMetadataCapabilities(effectiveDatabaseType.value));
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
const usesServerObjectPaging = computed(() => props.connection.db_type === "oceanbase-oracle");
const objectPageSize = computed(() => Math.max(1, Math.min(10_000, Math.floor(settingsStore.desktopSettings?.sidebar_table_page_size || 500))));
const isGaussdbM = computed(() => effectiveDatabaseType.value === "gaussdb" && props.connection.driver_profile?.toLowerCase() === "gaussdb-m");
const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics");
const isMongodb = computed(() => props.connection.db_type === "mongodb");
@@ -327,7 +335,7 @@ const supportsObjectSizeStats = computed(() => !isVictoriaMetrics.value && effec
// The batch table toolbar (export/copy/truncate/empty/drop selected) is SQL-only:
// MongoDB collections are not dropped or truncated through it.
const supportsBatchTableActions = computed(() => !isVictoriaMetrics.value && !isMongodb.value && effectiveDatabaseType.value !== "nebula");
const showTableStatistics = computed(() => effectiveDatabaseType.value !== "nebula" && (objectFilter.value === "all" || objectFilter.value === "tables"));
const showTableStatistics = computed(() => !usesServerObjectPaging.value && effectiveDatabaseType.value !== "nebula" && (objectFilter.value === "all" || objectFilter.value === "tables"));
const showObjectRowStats = computed(() => showTableStatistics.value);
const showObjectSizeStats = computed(() => supportsObjectSizeStats.value && showTableStatistics.value);
const objectRowsLabel = computed(() => t(isVictoriaMetrics.value ? "objects.series" : "objects.rows"));
@@ -433,23 +441,25 @@ const vacuumRiskMessage = computed(() => (vacuumExecuting.value ? t("contextMenu
const sourceDialect = computed(() => codeMirrorSqlDialect(effectiveDatabaseType.value));
const sourceFormatDialect = computed<SqlFormatDialect>(() => sqlFormatDialectForDbType(effectiveDatabaseType.value));
const objectFilters = computed<ObjectFilter[]>(() =>
(
[
["all", objectCounts.value.all],
["tables", objectCounts.value.tables],
["views", objectCounts.value.views],
["materializedViews", objectCounts.value.materializedViews],
["procedures", objectCounts.value.procedures],
["functions", objectCounts.value.functions],
["triggers", objectCounts.value.triggers],
["events", objectCounts.value.events],
["sequences", objectCounts.value.sequences],
["packages", objectCounts.value.packages],
["types", objectCounts.value.types],
] as Array<[ObjectFilter, number]>
)
.filter(([filter, count]) => filter === "all" || count > 0)
.map(([filter]) => filter),
usesServerObjectPaging.value
? ["all", "tables", "views", "procedures", "functions", "sequences", "packages", "types"]
: (
[
["all", objectCounts.value.all],
["tables", objectCounts.value.tables],
["views", objectCounts.value.views],
["materializedViews", objectCounts.value.materializedViews],
["procedures", objectCounts.value.procedures],
["functions", objectCounts.value.functions],
["triggers", objectCounts.value.triggers],
["events", objectCounts.value.events],
["sequences", objectCounts.value.sequences],
["packages", objectCounts.value.packages],
["types", objectCounts.value.types],
] as Array<[ObjectFilter, number]>
)
.filter(([filter, count]) => filter === "all" || count > 0)
.map(([filter]) => filter),
);
const showObjectFilter = computed(() => objectFilters.value.length > 2);
// A scope that holds a single object kind makes "全部 14" and "表 14" the same number, so the
@@ -613,10 +623,24 @@ watch([sortKey, sortDirection], () => scrollObjectsToTop());
// Also jump to the top when the search query or object-type filter changes —
// filtered results bear no relation to the previous scroll position.
watch(search, (value) => {
scrollObjectsToTop();
emit("searchChange", value);
});
watch(
search,
(value) => {
scrollObjectsToTop();
emit("searchChange", value);
if (usesServerObjectPaging.value) {
clearTimeout(objectSearchTimer);
objectBrowserRowsLoadGuard.invalidate();
rows.value = [];
hasMoreObjects.value = false;
loadingMoreObjects.value = false;
loadingObjects.value = true;
if (!value.trim()) void loadObjects();
else objectSearchTimer = setTimeout(() => void loadObjects(), 300);
}
},
{ flush: "sync" },
);
watch(objectFilter, () => {
if (preserveObjectFilterScrollOnce) {
preserveObjectFilterScrollOnce = false;
@@ -806,11 +830,13 @@ function typeLabel(row: ObjectBrowserRow) {
}
function sortIconFor(key: ObjectBrowserSortKey) {
if (usesServerObjectPaging.value) return null;
if (sortKey.value !== key) return null;
return sortDirection.value === "asc" ? ArrowUp : ArrowDown;
}
function toggleSort(key: ObjectBrowserSortKey) {
if (usesServerObjectPaging.value) return;
if (sortKey.value === key) {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
return;
@@ -962,6 +988,7 @@ function removePinnedObjectBrowserRows(rows: readonly ObjectBrowserRow[]) {
}
function groupedFilteredRows() {
if (usesServerObjectPaging.value) return { rows: rows.value, depths: new Map<string, number>() };
return groupObjectBrowserRows({
rows: rows.value.filter(rowMatchesObjectFilter),
matchingRows: objectSearchSummary.value.matchingRows.filter(rowMatchesObjectFilter),
@@ -1197,7 +1224,7 @@ async function openTableInfo(row: ObjectBrowserRow, initialTab?: TableInfoTab) {
}
sidePanelRow.value = row;
sidePanelMode.value = "table-info";
sidePanelGuard.bump();
const epoch = sidePanelGuard.start();
// Reset state
tableColumns.value = [];
tableOverviewStats.value = null;
@@ -1225,14 +1252,17 @@ async function openTableInfo(row: ObjectBrowserRow, initialTab?: TableInfoTab) {
// partition status first so a persisted `partitions` tab is not rejected (and
// overwritten) before the probe lands.
await probeTablePartitionStatus();
if (sidePanelGuard.isStale(epoch)) return;
const firstTab = initialTab ?? tableInfoTab.value;
await selectTableInfoTab(firstTab);
}
async function selectTableInfoTab(tab: TableInfoTab) {
const epoch = sidePanelGuard.capture();
// The panel can be re-selected (or restored) without `openTableInfo`, so make
// sure the partition status has been probed before the tab list is consulted.
if (!tablePartitionStatusResolved.value) await probeTablePartitionStatus();
if (sidePanelGuard.isStale(epoch)) return;
const nextTab = tableInfoTabs.value.some((item) => item.id === tab) ? tab : tableInfoTabs.value[0]?.id;
if (!nextTab) return;
tableInfoTab.value = nextTab;
@@ -1254,11 +1284,17 @@ async function fetchTableOverview(force = false) {
const schema = row.schema || selectedSchema.value || props.database;
tableOverviewLoading.value = true;
try {
const [statistics, comment] = await Promise.all([api.listObjectStatistics(props.connection.id, props.database, schema).catch(() => [] as ObjectStatistics[]), api.getTableComment(props.connection.id, props.database, schema, row.name, props.catalog).catch(() => null)]);
const [statistics, comment] = await sidePanelGuard.wait(
epoch,
Promise.all([api.listObjectStatistics(props.connection.id, props.database, schema).catch(() => [] as ObjectStatistics[]), api.getTableComment(props.connection.id, props.database, schema, row.name, props.catalog).catch(() => null)]),
metadataLoadBudgetMs(props.connection.query_timeout_secs),
);
if (sidePanelGuard.isStale(epoch)) return;
tableOverviewStats.value = findTableStatistics(statistics, row.name, schema) ?? null;
tableOverviewComment.value = comment;
tableOverviewLoaded.value = true;
} catch (error) {
if (sidePanelGuard.isFresh(epoch)) toast(translateBackendError(t, error), 5000);
} finally {
if (sidePanelGuard.isFresh(epoch)) tableOverviewLoading.value = false;
}
@@ -1267,6 +1303,7 @@ async function fetchTableOverview(force = false) {
function tableMetadataRequest(row: ObjectBrowserRow): ObjectDdlRequest {
return {
connectionId: props.connection.id,
queryTimeoutSecs: props.connection.query_timeout_secs,
database: props.database || "",
schema: row.schema || selectedSchema.value || props.database,
tableName: row.name,
@@ -1282,7 +1319,7 @@ async function fetchTableDdl(force = settingsStore.editorSettings.refreshDdlOnOp
tableDdlLoading.value = true;
let loadedSuccessfully = false;
try {
const { ddl } = await loadObjectDdl(tableMetadataRequest(row), { force });
const { ddl } = await sidePanelGuard.wait(epoch, loadObjectDdl(tableMetadataRequest(row), { force }), metadataLoadBudgetMs(props.connection.query_timeout_secs));
if (sidePanelGuard.isStale(epoch)) return;
const formatDialect = sqlFormatDialectForDbType(effectiveDatabaseType.value);
const unqualified = applyDdlDatabaseQualifier(ddl, formatDialect, effectiveDatabaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.database, props.catalog);
@@ -1307,7 +1344,11 @@ async function fetchTableColumns(force = false) {
let loadedSuccessfully = false;
try {
const request = tableMetadataRequest(row);
const { value: columns } = await loadObjectMetadataFacet(request, "columns", () => api.getColumns(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
const { value: columns } = await sidePanelGuard.wait(
epoch,
loadObjectMetadataFacet(request, "columns", () => api.getColumns(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force }),
metadataLoadBudgetMs(props.connection.query_timeout_secs),
);
if (sidePanelGuard.isStale(epoch)) return;
tableColumns.value = columns;
loadedSuccessfully = true;
@@ -1331,7 +1372,11 @@ async function fetchTableIndexes(force = false) {
let loadedSuccessfully = false;
try {
const request = tableMetadataRequest(row);
const { value: indexes } = await loadObjectMetadataFacet(request, "indexes", () => api.listIndexes(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
const { value: indexes } = await sidePanelGuard.wait(
epoch,
loadObjectMetadataFacet(request, "indexes", () => api.listIndexes(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force }),
metadataLoadBudgetMs(props.connection.query_timeout_secs),
);
if (sidePanelGuard.isStale(epoch)) return;
tableIndexes.value = indexes;
loadedSuccessfully = true;
@@ -1355,7 +1400,11 @@ async function fetchTableForeignKeys(force = false) {
let loadedSuccessfully = false;
try {
const request = tableMetadataRequest(row);
const { value: fks } = await loadObjectMetadataFacet(request, "foreign-keys", () => api.listForeignKeys(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
const { value: fks } = await sidePanelGuard.wait(
epoch,
loadObjectMetadataFacet(request, "foreign-keys", () => api.listForeignKeys(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force }),
metadataLoadBudgetMs(props.connection.query_timeout_secs),
);
if (sidePanelGuard.isStale(epoch)) return;
tableForeignKeys.value = fks;
loadedSuccessfully = true;
@@ -1379,7 +1428,11 @@ async function fetchTableTriggers(force = false) {
let loadedSuccessfully = false;
try {
const request = tableMetadataRequest(row);
const { value: triggers } = await loadObjectMetadataFacet(request, "triggers", () => api.listTriggers(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
const { value: triggers } = await sidePanelGuard.wait(
epoch,
loadObjectMetadataFacet(request, "triggers", () => api.listTriggers(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force }),
metadataLoadBudgetMs(props.connection.query_timeout_secs),
);
if (sidePanelGuard.isStale(epoch)) return;
tableTriggers.value = triggers;
loadedSuccessfully = true;
@@ -1405,7 +1458,7 @@ async function probeTablePartitionStatus() {
const epoch = sidePanelGuard.capture();
try {
const request = tableMetadataRequest(row);
const status = await api.getTablePartitionStatus(request.connectionId, request.database, request.schema || request.database, request.tableName);
const status = await sidePanelGuard.wait(epoch, api.getTablePartitionStatus(request.connectionId, request.database, request.schema || request.database, request.tableName), metadataLoadBudgetMs(props.connection.query_timeout_secs));
if (sidePanelGuard.isStale(epoch)) return;
tableIsPartitioned.value = status.isPartitionedParent || status.isPartition;
} catch {
@@ -1426,7 +1479,7 @@ async function fetchTablePartitions(force = false) {
try {
const request = tableMetadataRequest(row);
// Live catalog read: partition metadata has no persisted cache facet.
const value = await api.getTablePartitioning(request.connectionId, request.database, request.schema || request.database, request.tableName);
const value = await sidePanelGuard.wait(epoch, api.getTablePartitioning(request.connectionId, request.database, request.schema || request.database, request.tableName), metadataLoadBudgetMs(props.connection.query_timeout_secs));
if (sidePanelGuard.isStale(epoch)) return;
tablePartitions.value = value;
loadedSuccessfully = true;
@@ -1451,7 +1504,11 @@ async function fetchTableConstraints(force = false) {
let loadedSuccessfully = false;
try {
const request = tableMetadataRequest(row);
const { value: constraints } = await loadObjectMetadataFacet(request, "constraints", () => api.listConstraints(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
const { value: constraints } = await sidePanelGuard.wait(
epoch,
loadObjectMetadataFacet(request, "constraints", () => api.listConstraints(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force }),
metadataLoadBudgetMs(props.connection.query_timeout_secs),
);
if (sidePanelGuard.isStale(epoch)) return;
tableConstraints.value = constraints;
loadedSuccessfully = true;
@@ -1640,17 +1697,21 @@ async function loadSourcePanel(row: ObjectBrowserRow, options?: { preserveEditin
const database = props.database;
const schema = row.schema || selectedSchema.value || database;
try {
const result = await api.getObjectSource(connectionId, database, schema, row.name, row.type as ObjectSourceKind, row.signature ?? undefined);
const result = await sidePanelGuard.wait(epoch, api.getObjectSource(connectionId, database, schema, row.name, row.type as ObjectSourceKind, row.signature ?? undefined), metadataLoadBudgetMs(props.connection.query_timeout_secs));
if (sidePanelGuard.isStale(epoch)) return;
sourceCanEdit.value = result.editable !== false && !["TRIGGER", "TYPE", "TYPE_BODY"].includes(row.type) && (row.type !== "SEQUENCE" || effectiveDatabaseType.value === "oceanbase-oracle");
const editable = sourceCanEdit.value
? await api.buildEditableObjectSource({
databaseType: effectiveDatabaseType.value,
objectType: row.type as ObjectSourceKind,
schema,
name: row.name,
source: result.source,
})
? await sidePanelGuard.wait(
epoch,
api.buildEditableObjectSource({
databaseType: effectiveDatabaseType.value,
objectType: row.type as ObjectSourceKind,
schema,
name: row.name,
source: result.source,
}),
metadataLoadBudgetMs(props.connection.query_timeout_secs),
)
: result.source;
if (sidePanelGuard.isStale(epoch)) return;
// Viewing database source must preserve its original whitespace and comments;
@@ -3193,7 +3254,7 @@ async function loadSchemas(epoch: number): Promise<boolean> {
const connectionId = props.connection.id;
const database = props.database;
try {
const names = filterSchemaNamesForConnection(await api.listSchemas(connectionId, database), props.connection, database, {
const names = filterSchemaNamesForConnection(await schemaListLoadGuard.wait(epoch, api.listSchemas(connectionId, database), metadataLoadBudgetMs(props.connection.query_timeout_secs)), props.connection, database, {
showSystemSchemas: props.connection.show_system_schemas === true,
});
if (!schemaListLoadGuard.isEpochCurrent(epoch)) return false;
@@ -3255,7 +3316,7 @@ function openInitialEventIfNeeded() {
function finishObjectBrowserRowsLoad() {
loadingObjects.value = false;
const preferredFilter = props.initialEventName || props.initialEventCreateRequestId !== undefined ? "events" : (props.selectedObjectFilter ?? props.initialObjectFilter ?? "tables");
if (!userHasSelectedFilter.value && objectCounts.value[preferredFilter] > 0) {
if (!usesServerObjectPaging.value && !userHasSelectedFilter.value && objectCounts.value[preferredFilter] > 0) {
// The default table filter is a presentation choice, not a user query
// change, so preserve the tab's saved scroll offset across remounts.
preserveObjectFilterScrollOnce = objectFilter.value !== "tables";
@@ -3278,6 +3339,8 @@ watch([() => props.initialEventName, () => props.initialEventOpenRequestId, () =
// (#8301)。经 objectListSchemaForConnection 回退到连接用户名(大写),仅限
// 达梦;oracle/oceanbase-oracle 维持空 schema 由后端解析当前 schema。
async function loadObjects(options?: { allowCached?: boolean; preserveExistingRows?: boolean }) {
clearTimeout(objectSearchTimer);
if (usesServerObjectPaging.value) return loadObjectPage(false);
error.value = "";
// A new load supersedes any in-flight one, so reset the transient refresh flags
// on entry. A superseded request's finally() can no longer run (the guard's
@@ -3331,7 +3394,8 @@ async function loadObjects(options?: { allowCached?: boolean; preserveExistingRo
}
try {
const nextRows = props.connection.db_type === "mongodb" ? await loadMongoObjectBrowserRows(request.scope.connectionId, request.scope.database) : await loadSqlObjectBrowserRows(request);
const load = props.connection.db_type === "mongodb" ? loadMongoObjectBrowserRows(request.scope.connectionId, request.scope.database) : loadSqlObjectBrowserRows(request);
const nextRows = await objectBrowserRowsLoadGuard.wait(request.epoch, load, metadataLoadBudgetMs(props.connection.query_timeout_secs));
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
applyObjectBrowserRows(nextRows);
const cachedAt = cacheObjectBrowserRows(cacheWriteToken, nextRows);
@@ -3354,6 +3418,65 @@ async function loadObjects(options?: { allowCached?: boolean; preserveExistingRo
}
}
async function loadObjectPage(append: boolean) {
if (append && (loadingObjects.value || loadingMoreObjects.value || !hasMoreObjects.value)) return;
const schema = needsSchema.value ? objectListSchemaForConnection(props.connection, selectedSchema.value) : props.database;
const request = objectBrowserRowsLoadGuard.start(objectBrowserRowsCacheScope(schema));
const offset = append ? objectPageOffset : 0;
const pageSize = objectPageSize.value;
const filter = search.value.trim();
const typeFilter = objectFilter.value;
const objectTypes: Partial<Record<ObjectFilter, (SidebarObjectKind | "EVENT")[]>> = {
tables: ["TABLE"],
views: ["VIEW"],
materializedViews: ["MATERIALIZED_VIEW"],
procedures: ["PROCEDURE"],
functions: ["FUNCTION"],
triggers: ["TRIGGER"],
events: ["EVENT"],
sequences: ["SEQUENCE"],
packages: ["PACKAGE", "PACKAGE_BODY"],
types: ["TYPE", "TYPE_BODY"],
};
const isCurrent = () => objectBrowserRowsLoadGuard.isCurrent(request) && search.value.trim() === filter && objectFilter.value === typeFilter;
error.value = "";
scaffoldRefreshError.value = "";
loadingObjects.value = !append;
loadingMoreObjects.value = append;
if (!append) {
rows.value = [];
hasMoreObjects.value = false;
objectPageOffset = 0;
}
try {
if (parseSlashDelimitedRegexQuery(filter)) {
error.value = t("objects.pagedRegexUnsupported");
return;
}
const objects = await objectBrowserRowsLoadGuard.wait(
request.epoch,
api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, objectTypes[typeFilter], filter || undefined, pageSize + 1, offset, request.scope.catalog),
metadataLoadBudgetMs(props.connection.query_timeout_secs),
);
if (!isCurrent()) return;
const page = objects.slice(0, pageSize);
const pageRows = buildObjectBrowserRows({ objects: page, database: request.scope.database, fallbackSchema: request.scope.schema, rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value) });
applyObjectBrowserRows(append ? [...rows.value, ...pageRows] : pageRows);
objectPageOffset = offset + page.length;
hasMoreObjects.value = objects.length > pageSize;
} catch (e: unknown) {
if (!isCurrent()) return;
if (append) scaffoldRefreshError.value = translateBackendError(t, e);
else error.value = translateBackendError(t, e);
} finally {
if (isCurrent()) {
loadingObjects.value = false;
loadingMoreObjects.value = false;
if (!append) finishObjectBrowserRowsLoad();
}
}
}
async function loadSqlObjectBrowserRows(request: ObjectBrowserRowsLoadHandle) {
const objects: ObjectInfo[] = await api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, undefined, undefined, undefined, undefined, request.scope.catalog);
return buildObjectBrowserRows({
@@ -3454,7 +3577,7 @@ function onSchemaChange(value: any) {
selectedSchema.value = typeof value === "string" && value ? value : undefined;
emit("schemaChange", selectedSchema.value);
userHasSelectedFilter.value = false;
objectFilter.value = "all";
objectFilter.value = usesServerObjectPaging.value ? (props.selectedObjectFilter ?? props.initialObjectFilter ?? "tables") : "all";
void loadObjects();
}
@@ -3487,13 +3610,15 @@ function filterLabel(filter: ObjectFilter) {
: filter === "types"
? "tree.types"
: "objects.all";
return `${t(key)} ${filterCount(filter)}`;
return usesServerObjectPaging.value ? t(key) : `${t(key)} ${filterCount(filter)}`;
}
function selectObjectFilter(filter: ObjectFilter) {
if (filter === objectFilter.value) return;
userHasSelectedFilter.value = true;
objectFilter.value = filter;
emit("filterChange", filter);
if (usesServerObjectPaging.value) void loadObjects();
}
function getSearchInput(): HTMLInputElement | null {
@@ -3535,11 +3660,27 @@ function matchesRefreshScope(scope: { schema?: string; catalog?: string }): bool
defineExpose({ focusSearch, refresh, matchesRefreshScope });
onBeforeUnmount(() => {
clearTimeout(objectSearchTimer);
sidePanelGuard.bump();
objectBrowserRowsLoadGuard.invalidate();
schemaListLoadGuard.invalidate();
stopColumnResize?.();
});
watch(
() => connectionStore.metadataGenerationFor?.(props.connection.id, props.database),
() => {
clearTimeout(objectSearchTimer);
objectBrowserRowsLoadGuard.invalidate();
schemaListLoadGuard.invalidate();
closeSidePanel();
loadingSchemas.value = false;
loadingObjects.value = false;
loadingMoreObjects.value = false;
refreshingObjects.value = false;
},
);
watch(
[() => props.connection.id, () => props.database, () => props.schema],
async () => {
@@ -3547,7 +3688,7 @@ watch(
const contextEpoch = schemaListLoadGuard.invalidate();
selectedSchema.value = props.schema;
userHasSelectedFilter.value = false;
objectFilter.value = "all";
objectFilter.value = usesServerObjectPaging.value ? (props.selectedObjectFilter ?? props.initialObjectFilter ?? "tables") : "all";
clearTableSelection();
// Close side panel and invalidate any pending source/table-info requests
// so stale results from the old context don't overwrite new state.
@@ -3906,7 +4047,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
@update:model-value="onSchemaChange"
/>
<!-- Sort selector -->
<div v-if="showInlineSortAndView" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<div v-if="showInlineSortAndView && !usesServerObjectPaging" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<select
class="h-6 cursor-pointer appearance-none rounded-sm bg-transparent px-1.5 text-xs text-muted-foreground outline-none hover:text-foreground focus:text-foreground"
:value="sortKey"
@@ -3942,7 +4083,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
{{ t("objects.pasteTableSelected") }}
</Button>
<ToolbarOverflowMenu v-if="showToolbarOverflow" :label="t('toolbar.moreActions')" button-class="h-7 w-7">
<DropdownMenuSub>
<DropdownMenuSub v-if="!usesServerObjectPaging">
<DropdownMenuSubTrigger>
<ArrowDown class="h-3.5 w-3.5" />
{{ t("objects.sortBy") }}
@@ -3954,7 +4095,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @select="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
<DropdownMenuItem v-if="!usesServerObjectPaging" @select="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
<ArrowUp v-if="sortDirection === 'asc'" class="h-3.5 w-3.5" />
<ArrowDown v-else class="h-3.5 w-3.5" />
{{ sortDirection === "asc" ? t("objects.sortDesc") : t("objects.sortAsc") }}
@@ -4038,7 +4179,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('name')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :disabled="usesServerObjectPaging" @click="toggleSort('name')">
<span class="truncate">{{ t("objects.name") }}</span>
<component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" />
</button>
@@ -4047,7 +4188,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('type')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :disabled="usesServerObjectPaging" @click="toggleSort('type')">
<span class="truncate">{{ t("objects.type") }}</span>
<component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" />
</button>
@@ -4056,7 +4197,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
<div v-if="showObjectRowStats" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :disabled="usesServerObjectPaging" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
<span class="truncate">{{ objectRowsLabel }}</span>
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
</button>
@@ -4069,7 +4210,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
<div v-if="showObjectSizeStats" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :disabled="usesServerObjectPaging" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
<span class="truncate">{{ t("objects.size") }}</span>
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
</button>
@@ -4082,7 +4223,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
<div v-if="hasCreatedAt" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('created_at')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :disabled="usesServerObjectPaging" @click="toggleSort('created_at')">
<span class="truncate">{{ t("objects.createdAt") }}</span>
<component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" />
</button>
@@ -4095,7 +4236,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
<div v-if="hasUpdatedAt" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('updated_at')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :disabled="usesServerObjectPaging" @click="toggleSort('updated_at')">
<span class="truncate">{{ t("objects.updatedAt") }}</span>
<component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" />
</button>
@@ -4108,7 +4249,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('comment')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :disabled="usesServerObjectPaging" @click="toggleSort('comment')">
<span class="truncate">{{ t("objects.comment") }}</span>
<component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" />
</button>
@@ -4238,6 +4379,13 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</template>
</RecycleScroller>
</div>
<div v-if="usesServerObjectPaging" class="flex shrink-0 items-center justify-between gap-2 border-t px-3 py-2 text-xs text-muted-foreground">
<span>{{ t("objects.pagedOrder", { count: rows.length }) }}</span>
<Button v-if="hasMoreObjects" data-object-load-more variant="outline" size="sm" :disabled="loadingMoreObjects" @click="loadObjectPage(true)">
<Loader2 v-if="loadingMoreObjects" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
{{ t("tree.loadMore") }}
</Button>
</div>
</div>
<!-- Right-side panel: table info or source -->
<div
@@ -7,7 +7,7 @@ import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsC
import { invalidateMetadataRuntimeCachePrefix } from "@/lib/metadata/metadataRuntimeCache";
import type { ConnectionConfig } from "@/types/database";
const mocks = vi.hoisted(() => ({ listConstraints: vi.fn() }));
const mocks = vi.hoisted(() => ({ listConstraints: vi.fn(), getTableComment: vi.fn(), getTablePartitionStatus: vi.fn(), getTablePartitioning: vi.fn(), toast: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({
listObjects: vi.fn().mockResolvedValue([{ name: "O01_CONSTRAINTS", object_type: "TABLE", schema: "TESTER" }]),
listSchemas: vi.fn().mockResolvedValue(["TESTER"]),
@@ -17,6 +17,9 @@ vi.mock("@/lib/backend/api", () => ({
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listConstraints: (...args: unknown[]) => mocks.listConstraints(...args),
getTableComment: (...args: unknown[]) => mocks.getTableComment(...args),
getTablePartitionStatus: (...args: unknown[]) => mocks.getTablePartitionStatus(...args),
getTablePartitioning: (...args: unknown[]) => mocks.getTablePartitioning(...args),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
@@ -39,7 +42,7 @@ vi.mock("@/stores/settingsStore", () => ({
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref("en-US") }) }));
vi.mock("@/i18n", () => ({ default: { install: () => undefined } }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("vue-virtual-scroller", () => ({
RecycleScroller: defineComponent({
props: { items: { type: Array, default: () => [] } },
@@ -65,6 +68,11 @@ const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
beforeEach(() => {
vi.clearAllMocks();
connection.db_type = "oceanbase-oracle";
connection.query_timeout_secs = undefined;
mocks.getTableComment.mockReset().mockResolvedValue(null);
mocks.getTablePartitionStatus.mockReset().mockResolvedValue({ isPartitionedParent: true, isPartition: false });
mocks.getTablePartitioning.mockReset().mockResolvedValue(null);
mocks.listConstraints.mockResolvedValue([
{
name: "O01_UK",
@@ -80,6 +88,7 @@ beforeEach(() => {
invalidateMetadataRuntimeCachePrefix("");
});
afterEach(() => {
vi.useRealTimers();
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
@@ -102,6 +111,36 @@ async function openConstraints() {
}
describe("OceanBase Oracle constraint information", () => {
it.each([120, 0])("keeps a constraint request alive beyond 35 seconds with timeout %s", async (timeout) => {
vi.useFakeTimers();
connection.query_timeout_secs = timeout;
let resolve!: (rows: unknown[]) => void;
mocks.listConstraints.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
const host = await openConstraints();
await vi.waitFor(() => expect(mocks.listConstraints).toHaveBeenCalled());
await vi.advanceTimersByTimeAsync(40_000);
resolve([{ name: "SLOW_CONSTRAINT", constraint_type: "UNIQUE", definition: "UNIQUE (A)", columns: ["A"], ref_columns: [], enabled: true, valid: true }]);
await vi.advanceTimersByTimeAsync(0);
expect(host.textContent).toContain("SLOW_CONSTRAINT");
expect(mocks.toast).not.toHaveBeenCalledWith(expect.stringContaining("Metadata loading timed out"), 5000);
});
it.each(["overview", "partitions"])("ends a stalled %s metadata wait through the actual detail tab", async (kind) => {
// Partition tabs are currently PostgreSQL-only; keep their shared lifecycle bounded too.
if (kind === "partitions") connection.db_type = "postgres";
const host = await openConstraints();
vi.useFakeTimers();
const stalled = kind === "overview" ? mocks.getTableComment : mocks.getTablePartitioning;
stalled.mockReturnValueOnce(new Promise(() => undefined));
const title = kind === "overview" ? "grid.tableInfoOverview" : "structureEditor.partitions";
host.querySelector<HTMLButtonElement>(`button[title="${title}"]`)!.click();
await vi.advanceTimersByTimeAsync(35_001);
expect(stalled).toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("Metadata loading timed out"), 5000);
});
it("opens its read-only Constraints tab and displays ordered metadata from the agent", async () => {
const host = await openConstraints();
await vi.waitFor(() => expect(host.textContent).toContain('UNIQUE ("B", "A")'));
@@ -0,0 +1,109 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { expect, it, vi } from "vitest";
import ObjectBrowser from "../ObjectBrowser.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { ConnectionConfig } from "@/types/database";
const api = vi.hoisted(() => ({
listObjects: vi.fn(),
listSchemas: vi.fn().mockResolvedValue(["APP"]),
listObjectStatistics: vi.fn().mockResolvedValue([]),
disconnectDb: vi.fn().mockResolvedValue(undefined),
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/backend/api", () => api);
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref("en-US") }) }));
vi.mock("@/i18n", () => ({ default: { install: () => undefined, global: { t: (key: string) => key } } }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
vi.mock("vue-virtual-scroller", () => ({
RecycleScroller: defineComponent({
props: ["items"],
setup:
(props, { slots }) =>
() =>
h(
"div",
props.items.map((item: unknown) => slots.default?.({ item })),
),
}),
}));
vi.mock("@/components/ui/searchable-select", () => ({ SearchableSelect: { render: () => null } }));
vi.mock("@/components/ui/ToolbarOverflowMenu.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/objects/ProcedureExecutionDialog.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/objects/CustomTypeInfoPanel.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({ default: { render: () => null } }));
it("retires an actual ObjectBrowser page when the real store disconnects the same connection", async () => {
const pinia = createPinia();
setActivePinia(pinia);
const store = useConnectionStore();
const settings = useSettingsStore();
settings.editorSettings.disconnectTabHandlingMode = "keep-tabs-clear-results";
const connection = { id: "ob-real-generation", name: "OB", db_type: "oceanbase-oracle", database: "APP", transport_layers: [] } as unknown as ConnectionConfig;
store.connections = [connection];
store.connectedIds.add(connection.id);
let resolve!: (rows: unknown[]) => void;
api.listObjects.mockReturnValue(
new Promise((done) => {
resolve = done;
}),
);
const host = document.createElement("div");
document.body.append(host);
const app = createApp({ setup: () => () => h(ObjectBrowser, { connection, database: "APP", schema: "APP", selectedObjectFilter: "all" }) });
app.use(pinia);
try {
app.mount(host);
await vi.waitFor(() => expect(api.listObjects).toHaveBeenCalled());
await store.disconnect(connection.id);
resolve([{ name: "STALE_AFTER_DISCONNECT", schema: "APP", object_type: "TABLE" }]);
await new Promise((done) => setTimeout(done, 10));
expect(host.textContent).not.toContain("STALE_AFTER_DISCONNECT");
} finally {
app.unmount();
host.remove();
}
});
it("cancels a pending object search when the real store disconnects", async () => {
vi.useFakeTimers();
const pinia = createPinia();
setActivePinia(pinia);
const store = useConnectionStore();
useSettingsStore().editorSettings.disconnectTabHandlingMode = "keep-tabs-clear-results";
const connection = { id: "ob-pending-search", name: "OB", db_type: "oceanbase-oracle", database: "APP", transport_layers: [] } as unknown as ConnectionConfig;
store.connections = [connection];
store.connectedIds.add(connection.id);
api.listObjects.mockReset().mockResolvedValue([]);
const host = document.createElement("div");
document.body.append(host);
const app = createApp({ setup: () => () => h(ObjectBrowser, { connection, database: "APP", schema: "APP", selectedObjectFilter: "all" }) });
app.use(pinia);
try {
app.mount(host);
await vi.waitFor(() => expect(api.listObjects).toHaveBeenCalledTimes(1));
const input = host.querySelector<HTMLInputElement>("[data-object-search-input]")!;
input.value = "PENDING_SEARCH";
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
await store.disconnect(connection.id);
await nextTick();
await vi.advanceTimersByTimeAsync(350);
expect(api.listObjects).toHaveBeenCalledTimes(1);
} finally {
app.unmount();
host.remove();
vi.useRealTimers();
}
});
@@ -80,7 +80,11 @@ gridStyle.textContent = ".object-browser-grid-wrapper { padding: 8px; }";
beforeEach(() => {
vi.clearAllMocks();
invalidateObjectBrowserRowsCache({});
mocks.listObjects.mockResolvedValue(objects);
mocks.listObjects.mockImplementation((_connectionId, _database, _schema, _types, filter) => {
if (filter === "nEeDlE") return Promise.resolve(objects.slice(0, 3));
if (filter === "100%_") return Promise.resolve([objects[0]]);
return Promise.resolve(objects);
});
document.head.append(gridStyle);
});
@@ -98,6 +102,7 @@ async function searchObjects(host: HTMLElement, query: string) {
expect(input).not.toBeNull();
input!.value = query;
input!.dispatchEvent(new Event("input", { bubbles: true }));
await vi.waitFor(() => expect(mocks.listObjects.mock.calls.at(-1)?.[4]).toBe(query || undefined));
await nextTick();
}
@@ -0,0 +1,224 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ObjectBrowser from "@/components/objects/ObjectBrowser.vue";
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
import type { ConnectionConfig, ObjectBrowserFilter, ObjectInfo } from "@/types/database";
const mocks = vi.hoisted(() => ({ listObjects: vi.fn(), listSchemas: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({
listObjects: (...args: unknown[]) => mocks.listObjects(...args),
listSchemas: (...args: unknown[]) => mocks.listSchemas(...args),
listObjectStatistics: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ getConfig: () => connection, ensureConnected: vi.fn().mockResolvedValue(undefined), orderByPinnedTreeNodes: (rows: unknown[]) => rows }),
}));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
desktopSettings: { sidebar_table_page_size: 2 },
editorSettings: { shortcuts: { refreshData: "F5" }, objectBrowserViewMode: "list", objectBrowserShowCheckbox: false },
}),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref("en-US") }) }));
vi.mock("@/i18n", () => ({ default: { install: () => undefined } }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("vue-virtual-scroller", () => ({
RecycleScroller: defineComponent({
props: { items: { type: Array, default: () => [] } },
setup(props, { slots }) {
return () =>
h(
"div",
props.items.map((item) => slots.default?.({ item })),
);
},
}),
}));
vi.mock("@/components/ui/searchable-select", () => ({ SearchableSelect: { render: () => null } }));
vi.mock("@/components/ui/ToolbarOverflowMenu.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/objects/ProcedureExecutionDialog.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/objects/CustomTypeInfoPanel.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({ default: { render: () => null } }));
const connection = { id: "ob-11418-pages", name: "OceanBase Oracle", db_type: "oceanbase-oracle", database: "APP", driver_profile: null, url_params: null, transport_layers: [] } as unknown as ConnectionConfig;
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
const object = (name: string, schema = "APP"): ObjectInfo => ({ name, schema, object_type: "TABLE", comment: "业务 Needle 100%_" });
beforeEach(() => {
vi.clearAllMocks();
mocks.listSchemas.mockReset().mockResolvedValue(["APP", "OTHER"]);
invalidateObjectBrowserRowsCache({});
});
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
invalidateObjectBrowserRowsCache({});
vi.useRealTimers();
vi.restoreAllMocks();
});
async function mountBrowser(selectedObjectFilter: ObjectBrowserFilter | null = "all") {
const schema = ref("APP");
const host = document.createElement("div");
document.body.append(host);
const app = createApp({ setup: () => () => h(ObjectBrowser, { connection, database: "APP", schema: schema.value, selectedObjectFilter: selectedObjectFilter ?? undefined }) });
mountedApps.push({ app, host });
app.mount(host);
await vi.waitFor(() => expect(mocks.listObjects).toHaveBeenCalled());
return { host, schema };
}
async function searchObjects(host: HTMLElement, query: string) {
const input = host.querySelector<HTMLInputElement>("[data-object-search-input]")!;
input.value = query;
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
}
describe("OceanBase Oracle object pagination (#11418)", () => {
it("ends local waiting when the object request never returns", async () => {
vi.useFakeTimers();
mocks.listObjects.mockReturnValue(new Promise(() => undefined));
const { host } = await mountBrowser();
await vi.advanceTimersByTimeAsync(60_001);
expect(host.textContent).toContain("timed out");
});
it("releases a stalled schema wait and clears its timer on unmount", async () => {
vi.useFakeTimers();
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
mocks.listSchemas.mockReturnValueOnce(new Promise(() => undefined));
mocks.listObjects.mockResolvedValue([object("CURRENT")]);
const { host } = await mountBrowser();
await vi.advanceTimersByTimeAsync(60_001);
expect(host.querySelector('[title="CURRENT"]')).not.toBeNull();
expect(warning).toHaveBeenCalled();
const mounted = mountedApps.pop()!;
mounted.app.unmount();
mounted.host.remove();
expect(vi.getTimerCount()).toBe(0);
warning.mockRestore();
});
it("starts the default table tab with a typed request", async () => {
mocks.listObjects.mockResolvedValue([object("O11418_1")]);
await mountBrowser(null);
expect(mocks.listObjects).toHaveBeenCalledWith(connection.id, "APP", "APP", ["TABLE"], undefined, 3, 0, undefined);
});
it("offers the types filter before any types are loaded and requests both specifications and bodies", async () => {
mocks.listObjects.mockResolvedValueOnce([object("FIRST_TABLE")]).mockResolvedValue([
{ ...object("CUSTOM_TYPE"), object_type: "TYPE" },
{ ...object("CUSTOM_TYPE_BODY"), object_type: "TYPE_BODY" },
]);
const { host } = await mountBrowser();
const types = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === "tree.types");
expect(types).toBeDefined();
types!.click();
await vi.waitFor(() => expect(mocks.listObjects).toHaveBeenCalledTimes(2));
expect(mocks.listObjects).toHaveBeenLastCalledWith(connection.id, "APP", "APP", ["TYPE", "TYPE_BODY"], undefined, 3, 0, undefined);
await vi.waitFor(() => expect(host.querySelector('[title="CUSTOM_TYPE"]')).not.toBeNull());
expect(host.querySelector('[title="CUSTOM_TYPE_BODY"]')).not.toBeNull();
expect(host.querySelector('[title="FIRST_TABLE"]')).toBeNull();
});
it("requests bounded pages and appends in server order without displaying the lookahead row", async () => {
mocks.listObjects.mockResolvedValueOnce([object("O11418_10"), object("O11418_2"), object("O11418_3")]).mockResolvedValueOnce([object("O11418_3")]);
const { host } = await mountBrowser();
await vi.waitFor(() => expect(host.querySelector('[title="O11418_10"]')).not.toBeNull());
expect(mocks.listObjects).toHaveBeenNthCalledWith(1, connection.id, "APP", "APP", undefined, undefined, 3, 0, undefined);
expect(host.querySelector('[title="O11418_3"]')).toBeNull();
expect(Array.from(host.querySelectorAll('[title^="O11418_"]')).map((el) => el.getAttribute("title"))).toEqual(["O11418_10", "O11418_2"]);
const more = host.querySelector<HTMLButtonElement>("[data-object-load-more]");
expect(more).not.toBeNull();
more!.click();
await vi.waitFor(() => expect(host.querySelector('[title="O11418_3"]')).not.toBeNull());
expect(mocks.listObjects).toHaveBeenNthCalledWith(2, connection.id, "APP", "APP", undefined, undefined, 3, 2, undefined);
expect(host.querySelector("[data-object-load-more]")).toBeNull();
});
it("searches beyond loaded rows and resets the page for a type absent from the first page", async () => {
const matched = { ...object("O11418_LATE_VIEW"), object_type: "VIEW", comment: "n_e_e_d_l_e" };
mocks.listObjects.mockResolvedValueOnce([object("O11418_1"), object("O11418_2"), object("O11418_3")]).mockResolvedValue([matched]);
const { host } = await mountBrowser();
await vi.waitFor(() => expect(host.querySelector('[title="O11418_1"]')).not.toBeNull());
await searchObjects(host, "needle");
await vi.waitFor(() => expect(host.querySelector('[title="O11418_LATE_VIEW"]')).not.toBeNull());
expect(host.querySelector('[title="O11418_1"]')).toBeNull();
expect(mocks.listObjects).toHaveBeenNthCalledWith(2, connection.id, "APP", "APP", undefined, "needle", 3, 0, undefined);
const views = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === "objects.views");
expect(views).toBeDefined();
views!.click();
await vi.waitFor(() => expect(mocks.listObjects).toHaveBeenCalledTimes(3));
expect(mocks.listObjects).toHaveBeenNthCalledWith(3, connection.id, "APP", "APP", ["VIEW"], "needle", 3, 0, undefined);
await vi.waitFor(() => expect(host.querySelector('[title="O11418_LATE_VIEW"]')).not.toBeNull());
});
it("explains unsupported regex without fetching a full object list", async () => {
mocks.listObjects.mockResolvedValue([object("O11418_1")]);
const { host } = await mountBrowser();
await vi.waitFor(() => expect(host.querySelector('[title="O11418_1"]')).not.toBeNull());
await searchObjects(host, "/needle/i");
await vi.waitFor(() => expect(host.textContent).toContain("objects.pagedRegexUnsupported"));
expect(mocks.listObjects).toHaveBeenCalledTimes(1);
expect(host.querySelector('[title="O11418_1"]')).toBeNull();
});
it("coalesces typing into one bounded request while hiding the old list immediately", async () => {
mocks.listObjects.mockResolvedValueOnce([object("O11418_OLD")]).mockResolvedValue([object("O11418_NEW")]);
const { host } = await mountBrowser();
await vi.waitFor(() => expect(host.querySelector('[title="O11418_OLD"]')).not.toBeNull());
await searchObjects(host, "n");
await searchObjects(host, "needle");
expect(mocks.listObjects).toHaveBeenCalledTimes(1);
expect(host.querySelector('[title="O11418_OLD"]')).toBeNull();
await vi.waitFor(() => expect(host.querySelector('[title="O11418_NEW"]')).not.toBeNull());
expect(mocks.listObjects).toHaveBeenCalledTimes(2);
expect(mocks.listObjects.mock.calls[1]?.slice(4, 7)).toEqual(["needle", 3, 0]);
});
it.each(["filter", "schema", "refresh"] as const)("ignores a delayed page after %s changes the list", async (change) => {
let resolveOldPage!: (objects: ObjectInfo[]) => void;
const oldPage = new Promise<ObjectInfo[]>((resolve) => {
resolveOldPage = resolve;
});
mocks.listObjects
.mockResolvedValueOnce([object("O11418_1"), object("O11418_2"), object("O11418_3")])
.mockReturnValueOnce(oldPage)
.mockResolvedValue([object("O11418_FRESH", change === "schema" ? "OTHER" : "APP")]);
const { host, schema } = await mountBrowser();
await vi.waitFor(() => expect(host.querySelector("[data-object-load-more]")).not.toBeNull());
host.querySelector<HTMLButtonElement>("[data-object-load-more]")!.click();
await vi.waitFor(() => expect(mocks.listObjects).toHaveBeenCalledTimes(2));
if (change === "filter") await searchObjects(host, "fresh");
else if (change === "schema") schema.value = "OTHER";
else host.querySelector<HTMLButtonElement>('[title^="grid.refresh"]')!.click();
await vi.waitFor(() => expect(host.querySelector('[title="O11418_FRESH"]')).not.toBeNull());
expect(mocks.listObjects.mock.calls[2]?.[6]).toBe(0);
resolveOldPage([object("O11418_STALE")]);
await oldPage;
await nextTick();
expect(host.querySelector('[title="O11418_STALE"]')).toBeNull();
expect(host.querySelector('[title="O11418_FRESH"]')).not.toBeNull();
});
it("renders an empty final page without retaining a load-more action", async () => {
mocks.listObjects.mockResolvedValueOnce([object("O11418_1"), object("O11418_2"), object("O11418_3")]).mockResolvedValueOnce([]);
const { host } = await mountBrowser();
await vi.waitFor(() => expect(host.querySelector("[data-object-load-more]")).not.toBeNull());
host.querySelector<HTMLButtonElement>("[data-object-load-more]")!.click();
await vi.waitFor(() => expect(host.querySelector("[data-object-load-more]")).toBeNull());
expect(host.querySelector('[title="O11418_1"]')).not.toBeNull();
expect(host.querySelector('[title="O11418_2"]')).not.toBeNull();
});
});
@@ -135,6 +135,63 @@ function click(row: HTMLElement, detail: number) {
}
describe("ObjectBrowser routine double click", () => {
it("keeps a reopened source panel independent of the previous late response", async () => {
const host = await mountBrowser();
vi.useFakeTimers();
let release!: (value: { source: string; editable: boolean }) => void;
mocks.getObjectSource
.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}),
)
.mockResolvedValueOnce({ source: "FRESH_SOURCE", editable: true });
const row = rowFor(host, "sp_daily_report");
click(row, 1);
await vi.advanceTimersByTimeAsync(SINGLE_CLICK_DELAY);
click(row, 1);
await vi.advanceTimersByTimeAsync(SINGLE_CLICK_DELAY);
click(row, 1);
await vi.advanceTimersByTimeAsync(SINGLE_CLICK_DELAY);
expect(mocks.getObjectSource).toHaveBeenCalledTimes(2);
release({ source: "STALE_SOURCE", editable: true });
await vi.advanceTimersByTimeAsync(0);
expect(mocks.buildEditableObjectSource).toHaveBeenCalledTimes(1);
expect(mocks.buildEditableObjectSource).toHaveBeenCalledWith(expect.objectContaining({ source: "FRESH_SOURCE" }));
});
it("releases a pending source timer on unmount and discards a late source response", async () => {
const host = await mountBrowser();
vi.useFakeTimers();
let release!: (value: { source: string; editable: boolean }) => void;
mocks.getObjectSource.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}),
);
click(rowFor(host, "sp_daily_report"), 1);
await vi.advanceTimersByTimeAsync(SINGLE_CLICK_DELAY);
expect(mocks.getObjectSource).toHaveBeenCalledTimes(1);
const mounted = mountedApps.splice(
mountedApps.findIndex((item) => item.host === host),
1,
)[0];
mounted.app.unmount();
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(0);
release({ source: "LATE_SOURCE", editable: true });
await vi.advanceTimersByTimeAsync(0);
expect(mocks.buildEditableObjectSource).not.toHaveBeenCalled();
expect(host.textContent).not.toContain("LATE_SOURCE");
host.remove();
});
it("ends the source-panel wait when the backend never returns", async () => {
const host = await mountBrowser();
vi.useFakeTimers();
mocks.getObjectSource.mockReturnValueOnce(new Promise(() => undefined));
click(rowFor(host, "sp_daily_report"), 1);
await vi.advanceTimersByTimeAsync(SINGLE_CLICK_DELAY + 35_001);
expect(host.textContent).toContain("Metadata loading timed out");
});
it("opens an editable source tab for a double-clicked PROCEDURE", async () => {
const host = await mountBrowser();
vi.useFakeTimers();
@@ -1908,7 +1908,8 @@ async function openObjectBrowser(eventReadOnly = false, openEventEditor: boolean
return;
}
if (hasTreeNodeDatabaseContext(node)) {
const tabId = queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog, eventName, eventReadOnly, objectFilter, eventCreateRequestId);
const database = node.schema && connectionUsesVisibleSchemaFilter(connection) ? connection.database || node.database : node.database;
const tabId = queryStore.openObjectBrowser(node.connectionId, database, node.schema, node.catalog, eventName, eventReadOnly, objectFilter, eventCreateRequestId);
if (focusSearch) await nextTick(() => requestObjectBrowserSearchFocus(tabId));
return;
}
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { ConnectionConfig, TreeNode } from "@/types/database";
import type { ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
const api = vi.hoisted(() => ({ listSchemas: vi.fn(), listObjects: vi.fn(), checkConnectionHealth: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@/lib/backend/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/backend/api")>()),
...api,
listPlugins: vi.fn().mockResolvedValue([]),
listObjectStatistics: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
prewarmConnection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("vue-virtual-scroller", () => ({
RecycleScroller: defineComponent({
props: ["items"],
setup:
(props, { slots }) =>
() =>
h(
"div",
props.items.map((item: unknown) => slots.default?.({ item })),
),
}),
}));
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
import SidebarTreeRuntimeHost from "../SidebarTreeRuntimeHost.vue";
import ObjectBrowser from "@/components/objects/ObjectBrowser.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
const mounted: { app: App; container: HTMLElement }[] = [];
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
api.listSchemas.mockResolvedValue(["SYS", "APP_OWNER"]);
api.listObjects.mockImplementation(async (_id, _database, schema) => (schema === "APP_OWNER" || schema === "node_database" ? [{ name: "TARGET_TABLE", schema, object_type: "TABLE" }] : []));
});
afterEach(() => {
for (const { app, container } of mounted.splice(0)) {
app.unmount();
container.remove();
}
});
it.each([
["oceanbase-oracle", "SYS", "APP_OWNER", "SYS"],
["oracle", "SYS", "APP_OWNER", "SYS"],
["dameng", "SYS", "APP_OWNER", "SYS"],
["oracle", "", "APP_OWNER", "APP_OWNER"],
["oceanbase-oracle", "", "APP_OWNER", "APP_OWNER"],
["postgres", "configured", "node_database", "node_database"],
["mysql", "configured", "node_database", "node_database"],
] as const)("opens %s schema objects with configured database %s and node database %s", async (dbType, configuredDatabase, nodeDatabase, expectedDatabase) => {
const pinia = createPinia();
setActivePinia(pinia);
const connection = {
id: `schema-browser-${dbType}-${configuredDatabase}`,
name: "Schema browser",
db_type: dbType,
database: configuredDatabase,
username: "SYS",
transport_layers: [],
visible_databases: ["SYS"],
visible_schemas: { [expectedDatabase]: ["APP_OWNER"] },
} as unknown as ConnectionConfig;
const store = useConnectionStore();
store.connections = [connection];
store.connectedIds.add(connection.id);
const queries = useQueryStore();
const node: TreeNode = { id: `${connection.id}:${nodeDatabase}:APP_OWNER`, label: "APP_OWNER", type: "schema", connectionId: connection.id, database: nodeDatabase, schema: "APP_OWNER" };
const runtime = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const container = document.createElement("div");
document.body.append(container);
const app = createApp({
setup: () => () => {
const tab = queries.tabs.find((item) => item.mode === "objects");
return h("div", [h(SidebarTreeRuntimeHost, { ref: runtime, node, depth: 1 }), tab ? h(ObjectBrowser, { connection, database: tab.database, schema: tab.objectBrowser?.schema }) : null]);
},
});
app.use(pinia);
app.use(i18n);
app.mount(container);
mounted.push({ app, container });
await nextTick();
const host = runtime.value as unknown as { buildContextMenu(node: TreeNode): ContextMenuItem[] };
const action = host.buildContextMenu(node).find((item) => item.label === i18n.global.t("contextMenu.openObjectBrowser"))?.action;
expect(action).toBeDefined();
await action?.();
expect(queries.tabs.find((item) => item.mode === "objects")).toMatchObject({ database: expectedDatabase, schema: "APP_OWNER" });
await vi.waitFor(() => expect(container.textContent).toContain("TARGET_TABLE"));
expect(api.listObjects.mock.calls.at(-1)?.slice(0, 3)).toEqual([connection.id, expectedDatabase, dbType === "mysql" ? "node_database" : "APP_OWNER"]);
});
@@ -489,6 +489,7 @@ function applyDdlGenerationPreferences(sql: string, dialect: SqlFormatDialect):
type StructureDdlRequest = {
connectionId: string;
queryTimeoutSecs?: number;
database: string;
schema: string;
tableName: string;
@@ -499,6 +500,7 @@ type StructureDdlRequest = {
function ddlRequest(): StructureDdlRequest {
return {
connectionId: props.connectionId,
queryTimeoutSecs: store.getConfig(props.connectionId)?.query_timeout_secs,
database: props.database,
schema: metadataSchema.value,
tableName: props.tableName,
@@ -2472,10 +2474,10 @@ async function hydrateRestoredDraftFromDatabase() {
let shouldRefreshPreview = false;
try {
await store.ensureConnected(connectionId);
let { value: nextColumns } = await loadObjectMetadataFacet({ connectionId, database, schema, tableName, catalog }, "columns", () => api.getColumns(connectionId, database, schema, tableName, catalog));
let { value: nextColumns } = await loadObjectMetadataFacet({ connectionId, database, schema, tableName, catalog, queryTimeoutSecs: store.getConfig(connectionId)?.query_timeout_secs }, "columns", () => api.getColumns(connectionId, database, schema, tableName, catalog));
if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) {
try {
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog });
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog, queryTimeoutSecs: store.getConfig(connectionId)?.query_timeout_secs });
rawDdlContent.value = await formatStructureDdl(ddl);
ddlFetched.value = true;
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
@@ -3007,7 +3009,7 @@ async function loadVisibleTableComment(force = false, preserveDraft = false) {
const loadPromise = (async () => {
try {
await store.ensureConnected(connectionId);
const { value } = await loadCachedTableComment({ connectionId, database, schema, tableName, catalog }, force);
const { value } = await loadCachedTableComment({ connectionId, database, schema, tableName, catalog, queryTimeoutSecs: store.getConfig(connectionId)?.query_timeout_secs }, force);
if (requestId !== tableCommentLoadRequestId) return;
if (connectionId !== props.connectionId || database !== props.database || schema !== metadataSchema.value || tableName !== props.tableName || (catalog || "") !== (props.catalog || "")) return;
if (value === undefined) return;
@@ -3105,7 +3107,7 @@ async function loadTableOwner(force = false, preserveDraft = false) {
tableOwnerLoadError.value = "";
try {
await store.ensureConnected(connectionId);
const result = await loadObjectMetadataFacet({ connectionId, database, schema, tableName, catalog }, "owner", () => api.getTableOwner(connectionId, database, schema, tableName), { force });
const result = await loadObjectMetadataFacet({ connectionId, database, schema, tableName, catalog, queryTimeoutSecs: store.getConfig(connectionId)?.query_timeout_secs }, "owner", () => api.getTableOwner(connectionId, database, schema, tableName), { force });
if (requestId !== tableOwnerLoadRequestId) return;
const owner = result.value || "";
originalTableOwner.value = owner;
@@ -3231,7 +3233,7 @@ async function loadStructure(
if (nextColumns) {
if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) {
try {
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }, { force: options.forceDdl });
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog, queryTimeoutSecs: store.getConfig(connectionId)?.query_timeout_secs }, { force: options.forceDdl });
rawDdlContent.value = await formatStructureDdl(ddl);
ddlFetched.value = true;
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
@@ -3383,7 +3385,7 @@ async function revalidateCachedStructureMetadata(loadRequestId: number, scope: {
const tableName = props.tableName;
if (!connectionId || !database || !tableName) return;
const revalidationId = ++structureMetadataRevalidationId;
const metadataRequest = { connectionId, database, schema, tableName, catalog };
const metadataRequest = { connectionId, database, schema, tableName, catalog, queryTimeoutSecs: store.getConfig(connectionId)?.query_timeout_secs };
try {
await store.ensureConnected(connectionId);
// Force alone only clears this facet's own key; the web backend keeps its
@@ -94,7 +94,7 @@ describe("useDataGridTableMetadataLoaders", () => {
generateSqlQuoteIdentifiers: true,
},
} as any,
connectionStore: {} as any,
connectionStore: { getConfig: () => undefined } as any,
resolvedDatabaseType: computed(() => resolvedDatabaseType.value),
canShowTableIndexes: computed(() => true),
showTableInfo,
@@ -141,7 +141,7 @@ describe("useDataGridTableMetadataLoaders", () => {
generateSqlQuoteIdentifiers: true,
},
} as any,
connectionStore: {} as any,
connectionStore: { getConfig: () => undefined } as any,
resolvedDatabaseType: computed(() => resolvedDatabaseType.value),
canShowTableIndexes: computed(() => true),
showTableInfo,
@@ -187,7 +187,7 @@ describe("useDataGridTableMetadataLoaders", () => {
generateSqlQuoteIdentifiers: true,
},
} as any,
connectionStore: {} as any,
connectionStore: { getConfig: () => undefined } as any,
resolvedDatabaseType: computed(() => resolvedDatabaseType.value),
canShowTableIndexes: computed(() => true),
showTableInfo,
@@ -113,6 +113,7 @@ export function useDataGridTableMetadataLoaders(options: DataGridTableMetadataLo
if (!props.connectionId || !props.tableMeta) return undefined;
return {
connectionId: props.connectionId,
queryTimeoutSecs: options.connectionStore.getConfig(props.connectionId)?.query_timeout_secs,
database: props.database || "",
schema: defaultSchema(),
tableName: props.tableMeta.tableName,
+2
View File
@@ -5353,6 +5353,8 @@ export default {
schema: "Schema",
search: "Search tables, views, functions, sequences, or procedures...",
searchCollections: "Search collections...",
pagedRegexUnsupported: "Regular expressions are unavailable for paged object searches. Search by name or comment text.",
pagedOrder: "{count} loaded in database order. Refresh after object changes.",
loading: "Loading objects...",
empty: "No objects found",
selectedTables: "{count} tables selected",
+2
View File
@@ -4788,6 +4788,8 @@ export default withEnglishFallback({
schema: "Esquema",
search: "Buscar tablas, vistas, funciones, secuencias o procedimientos...",
searchCollections: "Buscar colecciones...",
pagedRegexUnsupported: "La búsqueda paginada de objetos no admite expresiones regulares. Introduzca un nombre o un comentario.",
pagedOrder: "Se han cargado {count} objetos en el orden de la base de datos. Actualice después de modificar los objetos.",
loading: "Cargando objetos...",
empty: "No se encontraron objetos",
selectedTables: "{count} tablas seleccionadas",
+2
View File
@@ -4608,6 +4608,8 @@ export default withEnglishFallback({
schema: "Schema",
search: "Cerca tabelle, viste, funzioni, sequenze o procedure...",
searchCollections: "Cerca collezioni...",
pagedRegexUnsupported: "La ricerca paginata degli oggetti non supporta espressioni regolari. Inserire un nome o un commento.",
pagedOrder: "Caricati {count} oggetti nell’ordine del database. Aggiornare dopo le modifiche agli oggetti.",
loading: "Caricamento oggetti...",
empty: "Nessun oggetto trovato",
selectedTables: "{count} tabelle selezionate",
+2
View File
@@ -4670,6 +4670,8 @@ export default withEnglishFallback({
schema: "スキーマ",
search: "テーブル、ビュー、関数、シーケンス、プロシージャを検索...",
searchCollections: "コレクションを検索...",
pagedRegexUnsupported: "ページ分割されたオブジェクト検索では正規表現を使用できません。名前またはコメントを入力してください。",
pagedOrder: "データベースの順序で {count} 個のオブジェクトを読み込みました。オブジェクトを変更した後は更新してください。",
loading: "オブジェクトを読み込み中...",
empty: "オブジェクトが見つかりません",
selectedTables: "{count}テーブル選択中",
+2
View File
@@ -4668,6 +4668,8 @@ export default withEnglishFallback({
schema: "스키마",
search: "테이블, 뷰, 함수, 시퀀스 또는 프로시저 검색...",
searchCollections: "컬렉션 검색...",
pagedRegexUnsupported: "페이지 단위 객체 검색은 정규식을 지원하지 않습니다. 이름 또는 주석을 입력하세요.",
pagedOrder: "객체 {count}개를 데이터베이스 순서대로 불러왔습니다. 객체 변경 후 새로 고치세요.",
loading: "객체를 불러오는 중...",
empty: "객체가 없습니다",
selectedTables: "테이블 {count}개 선택됨",
+2
View File
@@ -4608,6 +4608,8 @@ export default withEnglishFallback({
schema: "Schema",
search: "Pesquisar tabelas, visões, funções, sequências ou procedimentos...",
searchCollections: "Pesquisar coleções...",
pagedRegexUnsupported: "A busca paginada de objetos não suporta expressões regulares. Insira um nome ou comentário.",
pagedOrder: "{count} objetos carregados na ordem do banco de dados. Atualize após alterar os objetos.",
loading: "Carregando objetos...",
empty: "Nenhum objeto encontrado",
selectedTables: "{count} tabelas selecionadas",
+2
View File
@@ -5147,6 +5147,8 @@ export default withEnglishFallback({
schema: "Схема",
search: "Поиск таблиц, представлений, функций, последовательностей или процедур...",
searchCollections: "Поиск коллекций...",
pagedRegexUnsupported: "Постраничный поиск объектов не поддерживает регулярные выражения. Введите имя или текст комментария.",
pagedOrder: "Загружено объектов: {count}, в порядке базы данных. После изменения объектов обновите список.",
loading: "Загрузка объектов...",
empty: "Объекты не найдены",
selectedTables: "Выбрано таблиц: {count}",
+2
View File
@@ -5344,6 +5344,8 @@ export default withEnglishFallback({
schema: "Schema",
search: "搜索表、视图、函数、序列或存储过程...",
searchCollections: "搜索集合...",
pagedRegexUnsupported: "分页对象搜索暂不支持正则表达式,请输入名称或注释文本。",
pagedOrder: "已加载 {count} 个对象,按数据库顺序排列。对象变更后请刷新。",
loading: "正在加载对象...",
empty: "没有找到对象",
selectedTables: "已选择 {count} 张表",
+2
View File
@@ -4358,6 +4358,8 @@ export default withEnglishFallback({
schema: "Schema",
search: "搜尋資料表、檢視、函式、序列或預存程序……",
searchCollections: "搜尋集合...",
pagedRegexUnsupported: "分頁物件搜尋暫不支援正則運算式,請輸入名稱或註解文字。",
pagedOrder: "已載入 {count} 個物件,依資料庫順序排列。物件變更後請重新整理。",
loading: "正在載入物件……",
empty: "沒有找到物件",
selectedTables: "已選擇 {count} 張資料表",
@@ -16,6 +16,17 @@ function packageNode(): TreeNode {
}
describe("package member tree", () => {
it("keeps OceanBase overload identities when their signatures are unknown or identical", () => {
const candidates: CompletionAssistantCandidate[] = [
{ name: "RUN", kind: "procedure", signature: null, routine_id: "APP:101:1" },
{ name: "RUN", kind: "procedure", signature: null, routine_id: "APP:101:2" },
{ name: "RUN", kind: "procedure", signature: null, routine_id: "APP:101:2" },
{ name: "Run", kind: "procedure", signature: "", routine_id: "APP:101:3" },
];
const nodes = buildPackageMemberNodes(packageNode(), candidates, "oceanbase-oracle");
expect(nodes.map((node) => node.objectName)).toEqual(["RUN", "RUN", "Run"]);
expect(new Set(nodes.map((node) => node.id)).size).toBe(3);
});
it("marks package specifications as expandable without changing other objects", () => {
const nodes: TreeNode[] = [packageNode(), { id: "body", label: "business_api", type: "package-body" }, { id: "proc", label: "standalone", type: "procedure" }];
const result = markPackageNodesExpandable(nodes);
@@ -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);
});
@@ -14,6 +14,40 @@ import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion"
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts } from "@/lib/sql/sqlCompletionTriggerPolicy";
describe("OceanBase quoted routine lookup context", () => {
it("does not trust a cached package owner when the current schema is unknown", () => {
const sql = "CALL PKG.R";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "oceanbase-oracle",
dialect: "oracle",
tables: [],
columnsByTable: new Map(),
objects: [{ name: "RUN", schema: "OTHER", parentSchema: "OTHER", parentName: "PKG", type: "procedure", signature: "P NUMBER", routineId: "other-run" }],
});
expect(items.some((item) => item.label === "RUN")).toBe(false);
});
it.each([
["CALL PKG.R", "APP", "APP.PKG.RUN"],
["CALL OTHER.PKG.R", "APP", "OTHER.PKG.RUN"],
["CALL OTHER.R", "APP", "OTHER.RUN"],
["CALL PKG.R", "OTHER", "OTHER.PKG.RUN"],
])("keeps cached package owners scoped for %s in %s", (sql, currentSchema, expected) => {
const objects = [
{ name: "RUN", schema: "APP", parentSchema: "APP", parentName: "PKG", type: "procedure" as const, signature: "P VARCHAR2", routineId: "app-run" },
{ name: "RUN", schema: "OTHER", parentSchema: "OTHER", parentName: "PKG", type: "procedure" as const, signature: "P NUMBER", routineId: "other-run" },
{ name: "RUN", schema: "OTHER", type: "procedure" as const, signature: "P DATE", routineId: "standalone-run" },
];
const items = buildSqlCompletionItems(sql, sql.length, { databaseType: "oceanbase-oracle", dialect: "oracle", currentSchema, tables: [], columnsByTable: new Map(), objects });
expect(items.filter((item) => item.label === "RUN").map((item) => item.info?.split("\n")[0])).toEqual([expected]);
});
it.each(['CALL "Mixed.Owner"."Do', 'CALL "Mixed.Owner"."Do"'])("preserves quoted prefixes in %s", (sql) => {
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "oceanbase-oracle" });
expect(context).toMatchObject({ prefix: "Do", prefixQuoted: true, qualifierParts: ["Mixed.Owner"], qualifierQuoted: [true] });
});
});
describe("SQL completion replacement", () => {
const columnItem = { label: "price", type: "column" as const, apply: "price", boost: 0 };
+2 -2
View File
@@ -1244,8 +1244,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> {
+2 -1
View File
@@ -1793,7 +1793,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,
@@ -1801,6 +1801,7 @@ export async function getColumns(connectionId: string, database: string, schema:
table,
catalog,
clientSessionId,
currentSchema,
});
}
@@ -38,7 +38,7 @@ const OCEANBASE_ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIA
const DAMENG_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"];
const XUGU_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"];
const FIREBIRD_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION_INTERNAL", "FUNCTION_UDF", "TRIGGER", "SEQUENCE", "INDEX", "PACKAGE"];
const PACKAGE_MEMBER_EXPANSION_DATABASES = new Set<DatabaseType>(["oracle", "xugu"]);
const PACKAGE_MEMBER_EXPANSION_DATABASES = new Set<DatabaseType>(["oracle", "oceanbase-oracle", "xugu"]);
const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
// postgres
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const api = vi.hoisted(() => ({ loadSchemaCache: vi.fn(), saveSchemaCache: vi.fn(), deleteSchemaCachePrefix: vi.fn(), getTableDisplayDdl: vi.fn() }));
vi.mock("@/lib/backend/api", () => api);
import { cancelObjectDdlLoadsForDatabase, loadObjectDdl } from "../objectDdlCache";
import { cancelObjectMetadataLoadsForDatabase, loadObjectMetadataFacet } from "../objectMetadataCache";
import { clearMetadataRuntimeCache } from "../metadataRuntimeCache";
const request = { connectionId: "cancel-boundary", database: "app", schema: "TEST", tableName: "OBJECT" };
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
};
describe.each(["ddl", "facet"] as const)("%s cache cancellation boundary", (kind) => {
const load = (force = false) => (kind === "ddl" ? loadObjectDdl(request, { force }) : loadObjectMetadataFacet(request, "columns", api.getTableDisplayDdl, { force }));
const cancel = (database = request.database) => (kind === "ddl" ? cancelObjectDdlLoadsForDatabase(request.connectionId, database) : cancelObjectMetadataLoadsForDatabase(request.connectionId, database));
beforeEach(() => {
vi.resetAllMocks();
clearMetadataRuntimeCache();
api.loadSchemaCache.mockResolvedValue(null);
api.saveSchemaCache.mockResolvedValue(undefined);
api.deleteSchemaCachePrefix.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
it.each([120, 0])("honors a configured query timeout of %s beyond the default cache budget", async (queryTimeoutSecs) => {
vi.useFakeTimers();
const remote = deferred<string>();
api.getTableDisplayDdl.mockReturnValueOnce(remote.promise);
const configuredRequest = { ...request, queryTimeoutSecs };
const result = kind === "ddl" ? loadObjectDdl(configuredRequest) : loadObjectMetadataFacet(configuredRequest, "columns", api.getTableDisplayDdl);
const outcome = result.then(
(value) => ({ value }),
(error: unknown) => ({ error }),
);
await vi.advanceTimersByTimeAsync(40_000);
remote.resolve("configured result");
expect(await outcome).toMatchObject({ value: { cacheStatus: "remote" } });
expect(vi.getTimerCount()).toBe(0);
});
it("times out a stalled remote read, releases its cache slot, and ignores its late success", async () => {
vi.useFakeTimers();
const old = deferred<string>();
api.getTableDisplayDdl.mockReturnValueOnce(old.promise).mockResolvedValueOnce("fresh");
const result = load();
const rejected = expect(result).rejects.toThrow("Metadata loading timed out");
await vi.advanceTimersByTimeAsync(35_000);
await rejected;
expect(vi.getTimerCount()).toBe(0);
old.resolve("stale");
await Promise.resolve();
expect(api.saveSchemaCache).not.toHaveBeenCalled();
await expect(load()).resolves.toMatchObject({ cacheStatus: "remote" });
expect(api.getTableDisplayDdl).toHaveBeenCalledTimes(2);
});
it("cancels a forced load waiting for disk deletion without starting a late database read", async () => {
const deletion = deferred<void>();
api.deleteSchemaCachePrefix.mockReturnValueOnce(deletion.promise);
const result = load(true);
const rejected = expect(result).rejects.toThrow("Metadata waiting cancelled locally.");
await vi.waitFor(() => expect(api.deleteSchemaCachePrefix).toHaveBeenCalledTimes(1));
cancel();
await rejected;
deletion.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(api.getTableDisplayDdl).not.toHaveBeenCalled();
});
it("keeps another database request alive and rejects the cancelled disk read before it can cache late data", async () => {
const disk = deferred<unknown>();
api.loadSchemaCache.mockReturnValueOnce(disk.promise);
const result = load();
const rejected = expect(result).rejects.toThrow("Metadata waiting cancelled locally.");
let settled = false;
void result.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await vi.waitFor(() => expect(api.loadSchemaCache).toHaveBeenCalledTimes(1));
cancel("unrelated");
await Promise.resolve();
await Promise.resolve();
expect(settled).toBe(false);
cancel();
await rejected;
disk.resolve({ version: 1, cachedAt: new Date().toISOString(), ddl: "stale", value: "stale" });
await Promise.resolve();
api.getTableDisplayDdl.mockResolvedValueOnce("fresh");
await expect(load()).resolves.toMatchObject({ cacheStatus: "remote" });
expect(api.getTableDisplayDdl).toHaveBeenCalledTimes(1);
});
});
@@ -17,9 +17,28 @@ import { clearMetadataRuntimeCache } from "@/lib/metadata/metadataRuntimeCache";
const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const;
describe("objectDdlCache", () => {
it.each(["remote", "disk"])("settles local waiting when cancellation retires a never-returning %s read", async (phase) => {
if (phase === "disk") mocks.loadSchemaCache.mockReturnValueOnce(new Promise(() => undefined));
mocks.getTableDisplayDdl.mockReturnValueOnce(new Promise(() => undefined));
let settled = false;
const result = loadObjectDdl(request).then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await vi.waitFor(() => expect(phase === "remote" ? mocks.getTableDisplayDdl.mock.calls.length : getObjectDdlCacheDebugStateForTests().activeReads).toBe(1));
cancelObjectDdlLoadsForConnection(request.connectionId);
await vi.waitFor(() => expect(settled).toBe(true));
await result;
expect(getObjectDdlCacheDebugStateForTests().activeReads).toBe(0);
});
beforeEach(() => {
clearMetadataRuntimeCache();
vi.clearAllMocks();
mocks.getTableDisplayDdl.mockReset();
mocks.persisted.clear();
mocks.loadSchemaCache.mockImplementation(async (cacheKey: string) => mocks.persisted.get(cacheKey) ?? null);
mocks.saveSchemaCache.mockImplementation(async (cacheKey: string, payload: unknown) => {
@@ -257,7 +276,7 @@ describe("objectDdlCache", () => {
await vi.waitFor(() => expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1));
cancelObjectDdlLoadsForConnection(request.connectionId);
releaseOld("old session ddl");
await expect(oldLoad).resolves.toMatchObject({ ddl: "old session ddl" });
await expect(oldLoad).rejects.toThrow("Metadata waiting cancelled locally.");
mocks.getTableDisplayDdl.mockResolvedValueOnce("new session ddl");
await expect(loadObjectDdl(request)).resolves.toMatchObject({ ddl: "new session ddl", cacheStatus: "remote" });
@@ -16,6 +16,24 @@ import { clearMetadataRuntimeCache } from "@/lib/metadata/metadataRuntimeCache";
const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const;
describe("objectMetadataCache", () => {
it.each(["remote", "disk"])("settles local waiting when connection cancellation retires a never-returning %s read", async (phase) => {
if (phase === "disk") mocks.loadSchemaCache.mockReturnValueOnce(new Promise(() => undefined));
const loader = vi.fn(() => new Promise(() => undefined));
let settled = false;
const result = loadObjectMetadataFacet(request, "columns", loader).then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await vi.waitFor(() => expect(phase === "remote" ? loader.mock.calls.length : getObjectMetadataCacheDebugStateForTests().activeReads).toBe(1));
cancelObjectMetadataLoadsForConnection(request.connectionId);
await vi.waitFor(() => expect(settled).toBe(true));
await result;
expect(getObjectMetadataCacheDebugStateForTests().activeReads).toBe(0);
});
beforeEach(() => {
clearMetadataRuntimeCache();
vi.clearAllMocks();
@@ -270,7 +288,7 @@ describe("objectMetadataCache", () => {
await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1));
cancelObjectMetadataLoadsForConnection(request.connectionId);
releaseOld(["old session"]);
await expect(oldLoad).resolves.toMatchObject({ value: ["old session"] });
await expect(oldLoad).rejects.toThrow("Metadata waiting cancelled locally.");
loader.mockResolvedValueOnce(["new session"]);
await expect(loadObjectMetadataFacet(request, "columns", loader)).resolves.toMatchObject({ value: ["new session"], cacheStatus: "remote" });
@@ -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");
@@ -1,6 +1,7 @@
export interface ActiveCacheReadToken {
readonly cacheKey: string;
invalidated: boolean;
readonly controller: AbortController;
}
/** Tracks only currently running L2 reads, so invalidation state is bounded by concurrency. */
@@ -9,7 +10,7 @@ export class ActiveCacheReadTracker {
private count = 0;
begin(cacheKey: string): ActiveCacheReadToken {
const token: ActiveCacheReadToken = { cacheKey, invalidated: false };
const token: ActiveCacheReadToken = { cacheKey, invalidated: false, controller: new AbortController() };
const existing = this.readsByKey.get(cacheKey);
if (existing) existing.add(token);
else this.readsByKey.set(cacheKey, new Set([token]));
@@ -31,6 +32,16 @@ export class ActiveCacheReadTracker {
}
}
cancelPrefix(prefix: string): void {
for (const [cacheKey, reads] of this.readsByKey) {
if (!cacheKey.startsWith(prefix)) continue;
for (const token of reads) {
token.invalidated = true;
token.controller.abort(new Error("Metadata waiting cancelled locally."));
}
}
}
get activeCount(): number {
return this.count;
}
@@ -0,0 +1,28 @@
/** Local metadata waiting stays bounded even when server query timeouts are disabled. */
export function metadataLoadBudgetMs(queryTimeoutSecs?: number): number {
const seconds = Number(queryTimeoutSecs);
if (seconds === 0) return 60_000;
return Math.max(15_000, (Number.isFinite(seconds) && seconds > 0 ? seconds + 5 : 35) * 1000);
}
/** Ends local waiting; this does not assert that the server stopped its statement. */
export async function waitForMetadata<T>(promise: Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
let abort!: () => void;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
abort = () => reject(signal?.reason ?? new Error("Metadata waiting cancelled locally."));
if (signal?.aborted) abort();
else {
signal?.addEventListener("abort", abort, { once: true });
timer = setTimeout(() => reject(new Error(`Metadata loading timed out after ${Math.ceil(timeoutMs / 1000)}s.`)), timeoutMs);
}
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
signal?.removeEventListener("abort", abort);
}
}
+38 -11
View File
@@ -1,5 +1,6 @@
import * as api from "@/lib/backend/api";
import { ActiveCacheReadTracker } from "./activeCacheReadTracker";
import { metadataLoadBudgetMs, waitForMetadata } from "./metadataLoadBudget";
import type { ObjectSourceKind } from "@/types/database";
import type { MetadataCacheInvalidation } from "./metadataResultCache";
import { invalidateObjectMetadataCache } from "./objectMetadataCache";
@@ -24,6 +25,7 @@ interface InFlightDdlLoad {
export interface ObjectDdlRequest {
connectionId: string;
queryTimeoutSecs?: number;
database: string;
schema: string;
tableName: string;
@@ -38,6 +40,7 @@ export interface ObjectDdlLoadResult {
const remoteLoads = new Map<string, InFlightDdlLoad>();
const activeCacheReads = new ActiveCacheReadTracker();
const activeLoads = new ActiveCacheReadTracker();
const pendingInvalidations = new Map<string, Promise<void>>();
const pendingWrites = new Map<string, Promise<void>>();
@@ -134,16 +137,21 @@ function invalidatePersistedPrefix(prefix: string): Promise<void> {
return deletion;
}
async function loadRemoteDdl(request: ObjectDdlRequest, cacheKey: string, force: boolean): Promise<string> {
async function loadRemoteDdl(request: ObjectDdlRequest, cacheKey: string, force: boolean, signal: AbortSignal): Promise<string> {
signal.throwIfAborted();
const existing = remoteLoads.get(cacheKey);
if (existing && (!force || existing.force)) return existing.promise;
if (existing) invalidateRemoteLoad(cacheKey);
const entry: InFlightDdlLoad = { force, invalidated: false, promise: Promise.resolve("") };
const cancel = () => {
entry.invalidated = true;
if (remoteLoads.get(cacheKey) === entry) remoteLoads.delete(cacheKey);
};
signal.addEventListener("abort", cancel, { once: true });
recordMetadataCacheRemoteMiss(cacheKey);
entry.promise = api
.getTableDisplayDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog)
.then((ddl) => {
entry.promise = waitForMetadata(
api.getTableDisplayDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog).then((ddl) => {
const current = !entry.invalidated;
if (current && ddl.length <= MAX_PERSISTED_DDL_CHARS) {
const envelope: ObjectDdlCacheEnvelope = { version: 1, cachedAt: new Date().toISOString(), ddl };
@@ -153,15 +161,30 @@ async function loadRemoteDdl(request: ObjectDdlRequest, cacheKey: string, force:
setMetadataRuntimeCache(cacheKey, ddl, request.connectionId);
}
return ddl;
})
.finally(() => {
if (remoteLoads.get(cacheKey) === entry) remoteLoads.delete(cacheKey);
});
}),
metadataLoadBudgetMs(request.queryTimeoutSecs),
signal,
).finally(() => {
signal.removeEventListener("abort", cancel);
if (remoteLoads.get(cacheKey) === entry) remoteLoads.delete(cacheKey);
});
remoteLoads.set(cacheKey, entry);
return entry.promise;
}
export async function loadObjectDdl(request: ObjectDdlRequest, options?: { force?: boolean }): Promise<ObjectDdlLoadResult> {
const token = activeLoads.begin(objectDdlCacheKey(request));
try {
return await waitForMetadata(loadObjectDdlInternal(request, token.controller.signal, options), metadataLoadBudgetMs(request.queryTimeoutSecs), token.controller.signal);
} catch (error) {
token.controller.abort(error);
throw error;
} finally {
activeLoads.finish(token);
}
}
async function loadObjectDdlInternal(request: ObjectDdlRequest, signal: AbortSignal, options?: { force?: boolean }): Promise<ObjectDdlLoadResult> {
const cacheKey = objectDdlCacheKey(request);
if (options?.force) {
const existing = remoteLoads.get(cacheKey);
@@ -177,19 +200,21 @@ export async function loadObjectDdl(request: ObjectDdlRequest, options?: { force
await waitForPendingInvalidations(cacheKey);
}
signal.throwIfAborted();
if (!options?.force) {
const runtime = getMetadataRuntimeCache<string>(cacheKey);
if (runtime) return { ddl: runtime.value, cacheStatus: "memory" };
const readToken = activeCacheReads.begin(cacheKey);
let cached: string | null;
try {
cached = decodeCachedDdl(await loadSchemaCacheSafe<unknown>(cacheKey));
cached = decodeCachedDdl(await waitForMetadata(loadSchemaCacheSafe<unknown>(cacheKey), metadataLoadBudgetMs(request.queryTimeoutSecs), signal));
} finally {
activeCacheReads.finish(readToken);
}
signal.throwIfAborted();
if (readToken.invalidated) {
await waitForPendingInvalidations(cacheKey);
return { ddl: await loadRemoteDdl(request, cacheKey, false), cacheStatus: "remote" };
return { ddl: await loadRemoteDdl(request, cacheKey, false, signal), cacheStatus: "remote" };
}
if (cached !== null) {
recordMetadataCacheL2Hit(cacheKey, cached);
@@ -198,7 +223,7 @@ export async function loadObjectDdl(request: ObjectDdlRequest, options?: { force
}
}
return { ddl: await loadRemoteDdl(request, cacheKey, options?.force === true), cacheStatus: "remote" };
return { ddl: await loadRemoteDdl(request, cacheKey, options?.force === true, signal), cacheStatus: "remote" };
}
export async function invalidateObjectDdlCache(match: MetadataCacheInvalidation, options?: ObjectCacheInvalidationOptions): Promise<void> {
@@ -238,6 +263,7 @@ export async function invalidateObjectDdl(request: ObjectDdlRequest): Promise<vo
/** Invalidate active loads for a disconnected connection without deleting its persisted snapshot. */
export function cancelObjectDdlLoadsForConnection(connectionId: string): void {
const prefix = `${OBJECT_DDL_CACHE_PREFIX}:${cacheSegment(connectionId)}:`;
activeLoads.cancelPrefix(prefix);
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...remoteLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateRemoteLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
@@ -245,6 +271,7 @@ export function cancelObjectDdlLoadsForConnection(connectionId: string): void {
export function cancelObjectDdlLoadsForDatabase(connectionId: string, database: string): void {
const prefix = `${OBJECT_DDL_CACHE_PREFIX}:${cacheSegment(connectionId)}:${cacheSegment(database)}:`;
activeLoads.cancelPrefix(prefix);
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...remoteLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateRemoteLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
@@ -1,5 +1,6 @@
import * as api from "@/lib/backend/api";
import { ActiveCacheReadTracker } from "./activeCacheReadTracker";
import { metadataLoadBudgetMs, waitForMetadata } from "./metadataLoadBudget";
import type { MetadataCacheInvalidation } from "./metadataResultCache";
import type { ObjectDdlRequest } from "./objectDdlCache";
import { toObjectCacheInvalidationError, type ObjectCacheInvalidationOptions } from "./objectCacheInvalidationError";
@@ -23,6 +24,7 @@ interface InFlightLoad<T> {
const inFlightLoads = new Map<string, InFlightLoad<unknown>>();
const activeCacheReads = new ActiveCacheReadTracker();
const activeLoads = new ActiveCacheReadTracker();
const pendingInvalidations = new Map<string, Promise<void>>();
const pendingWrites = new Map<string, Promise<void>>();
@@ -122,6 +124,18 @@ function invalidatePersistedPrefix(prefix: string): Promise<void> {
}
export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, facet: ObjectMetadataFacet, loader: () => Promise<T>, options?: { force?: boolean }): Promise<{ value: T; cacheStatus: "memory" | "disk" | "remote" }> {
const token = activeLoads.begin(facetKey(request, facet));
try {
return await waitForMetadata(loadObjectMetadataFacetInternal(request, facet, loader, token.controller.signal, options), metadataLoadBudgetMs(request.queryTimeoutSecs), token.controller.signal);
} catch (error) {
token.controller.abort(error);
throw error;
} finally {
activeLoads.finish(token);
}
}
async function loadObjectMetadataFacetInternal<T>(request: ObjectDdlRequest, facet: ObjectMetadataFacet, loader: () => Promise<T>, signal: AbortSignal, options?: { force?: boolean }): Promise<{ value: T; cacheStatus: "memory" | "disk" | "remote" }> {
const cacheKey = facetKey(request, facet);
if (options?.force) {
const existing = inFlightLoads.get(cacheKey);
@@ -137,18 +151,20 @@ export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, face
await waitForPendingInvalidations(cacheKey);
}
signal.throwIfAborted();
if (!options?.force) {
const runtime = getMetadataRuntimeCache<T>(cacheKey);
if (runtime) return { value: runtime.value, cacheStatus: "memory" };
const readToken = activeCacheReads.begin(cacheKey);
let cached: ReturnType<typeof decodeEnvelope<T>>;
try {
cached = decodeEnvelope<T>(await loadSchemaCacheSafe<unknown>(cacheKey));
cached = decodeEnvelope<T>(await waitForMetadata(loadSchemaCacheSafe<unknown>(cacheKey), metadataLoadBudgetMs(request.queryTimeoutSecs), signal));
} finally {
activeCacheReads.finish(readToken);
}
signal.throwIfAborted();
if (readToken.invalidated) {
const value = await loadObjectMetadataFacet(request, facet, loader, { force: false });
const value = await loadObjectMetadataFacetInternal(request, facet, loader, signal, { force: false });
return { value: value.value, cacheStatus: "remote" };
}
if (cached?.hit) {
@@ -158,14 +174,20 @@ export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, face
}
}
signal.throwIfAborted();
const existing = inFlightLoads.get(cacheKey);
if (existing && (!options?.force || existing.force)) return { value: (await existing.promise) as T, cacheStatus: "remote" };
if (existing) invalidateInFlightLoad(cacheKey);
const entry: InFlightLoad<T> = { force: options?.force === true, invalidated: false, promise: Promise.resolve(undefined as T) };
const cancel = () => {
entry.invalidated = true;
if (inFlightLoads.get(cacheKey) === entry) inFlightLoads.delete(cacheKey);
};
signal.addEventListener("abort", cancel, { once: true });
recordMetadataCacheRemoteMiss(cacheKey);
entry.promise = loader()
.then((value) => {
entry.promise = waitForMetadata(
loader().then((value) => {
// Callers use undefined to signal that all metadata fallbacks failed.
// Keep that result visible to the current request, but do not turn a
// transient failure into a successful cache hit.
@@ -178,10 +200,13 @@ export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, face
persistSchemaCache(cacheKey, envelope, () => entry.invalidated);
}
return value;
})
.finally(() => {
if (inFlightLoads.get(cacheKey) === entry) inFlightLoads.delete(cacheKey);
});
}),
metadataLoadBudgetMs(request.queryTimeoutSecs),
signal,
).finally(() => {
signal.removeEventListener("abort", cancel);
if (inFlightLoads.get(cacheKey) === entry) inFlightLoads.delete(cacheKey);
});
inFlightLoads.set(cacheKey, entry as InFlightLoad<unknown>);
return { value: await entry.promise, cacheStatus: "remote" };
}
@@ -207,6 +232,7 @@ export async function invalidateObjectMetadataCache(match: MetadataCacheInvalida
/** Invalidate active loads for a disconnected connection without deleting its persisted snapshot. */
export function cancelObjectMetadataLoadsForConnection(connectionId: string): void {
const prefix = `${OBJECT_METADATA_CACHE_PREFIX}:${cacheSegment(connectionId)}:`;
activeLoads.cancelPrefix(prefix);
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...inFlightLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateInFlightLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
@@ -214,6 +240,7 @@ export function cancelObjectMetadataLoadsForConnection(connectionId: string): vo
export function cancelObjectMetadataLoadsForDatabase(connectionId: string, database: string): void {
const prefix = `${OBJECT_METADATA_CACHE_PREFIX}:${cacheSegment(connectionId)}:${cacheSegment(database)}:`;
activeLoads.cancelPrefix(prefix);
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...inFlightLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateInFlightLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
@@ -30,6 +30,7 @@ export interface TableMetadataRequest {
tableName: string;
tableType?: string;
databaseType: DatabaseType | string;
currentSchema?: string;
driverProfile?: string;
catalog?: string;
force?: boolean;
@@ -157,16 +158,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,
};
}
@@ -177,13 +178,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;
@@ -191,7 +192,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 };
@@ -248,12 +249,15 @@ 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);
const resolvedSchema = (request.databaseType === "vastbase" || request.databaseType === "kingbase") && !request.schema ? columns.find((column) => column.resolved_schema)?.resolved_schema : request.schema;
return {
schema: resolvedSchema || undefined,
tableName: request.tableName,
tableType: request.tableType,
tableType: resolvedTarget ? resolvedTarget.resolved_object_type?.trim() || "UNKNOWN" : request.tableType,
catalog: request.catalog,
database: request.database,
columns,
@@ -302,7 +306,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(() => {
@@ -341,25 +345,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,
@@ -33,9 +33,9 @@ function packageMemberGroup(packageNode: TreeNode, type: "procedure" | "function
};
}
function packageMemberNode(packageNode: TreeNode, kind: "procedure" | "function", name: string, signature: string): TreeNode {
function packageMemberNode(packageNode: TreeNode, kind: "procedure" | "function", name: string, signature: string, routineId?: string | null): TreeNode {
return {
id: `${packageNode.id}:member:${kind}:${name}:${signature}`,
id: routineId ? `${packageNode.id}:member:${JSON.stringify([kind, routineId])}` : `${packageNode.id}:member:${kind}:${name}:${signature}`,
label: signature ? `${name}(${signature})` : name,
type: kind,
objectName: name,
@@ -63,10 +63,10 @@ export function buildPackageMemberNodes(packageNode: TreeNode, candidates: reado
const name = candidate.name.trim();
if (!name) continue;
const signature = candidate.signature?.trim() || "";
const key = `${candidate.kind}\0${name}\0${signature}`;
const key = JSON.stringify([candidate.kind, name, candidate.routine_id ?? signature]);
if (seen.has(key)) continue;
seen.add(key);
const member = packageMemberNode(packageNode, candidate.kind, name, signature);
const member = packageMemberNode(packageNode, candidate.kind, name, signature, candidate.routine_id);
members.push(member);
if (candidate.kind === "procedure") procedures.push(member);
else functions.push(member);
@@ -72,7 +72,7 @@ export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds
const matcherOptions = typeof options === "function" ? undefined : options;
const matchLabel = query ? createSidebarLabelMatcher(query, matcherOptions) : undefined;
if (!matchLabel && searchableNodeTypes === undefined) return nodes;
return filterSidebarTreeWithMatcher(nodes, matchLabel, collapsedIds, searchableNodeTypes, resolveLabel);
return filterSidebarTreeWithMatcher(nodes, matchLabel, collapsedIds, searchableNodeTypes, resolveLabel, query);
}
export function reuseLiveSidebarTreeNodes(indexedNodes: TreeNode[], liveNodes: readonly TreeNode[]): TreeNode[] {
@@ -363,10 +363,14 @@ function preservedSearchChildren(node: TreeNode, collapsedIds: ReadonlySet<strin
return node.children.filter((child) => !hiddenSearchNodeTypes.has(child.type)).map((child) => applySearchCollapsedState(child, collapsedIds));
}
function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabelMatcher | undefined, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>, resolveLabel?: (node: TreeNode) => string): TreeNode[] {
function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabelMatcher | undefined, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>, resolveLabel?: (node: TreeNode) => string, query?: string): TreeNode[] {
const filteredNodes: { node: TreeNode; score: number }[] = [];
for (const node of nodes) {
if (node.type === "load-more" && node.loadMore?.searchQuery !== undefined && node.loadMore.searchQuery === query && (!searchableNodeTypes || nodes.some((sibling) => searchableNodeTypes.has(sibling.type)))) {
filteredNodes.push({ node, score: -1 });
continue;
}
// Utility groups never match by their own label; groups without children
// (or not yet loaded) drop out entirely, while groups that hold real
// objects (db links, tablespaces) survive through a matching child.
@@ -395,7 +399,7 @@ function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabe
// Connection utility entries are synthetic navigation actions, not schema
// search results. Keep real loaded descendants for connection-name matches,
// but do not let those actions make a disconnected result look expanded.
const filteredChildren = preservesSubtree ? preservedSearchChildren(node, collapsedIds) : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes, resolveLabel) : undefined;
const filteredChildren = preservesSubtree ? preservedSearchChildren(node, collapsedIds) : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes, resolveLabel, query) : undefined;
if (selfMatch || (filteredChildren && filteredChildren.length > 0)) {
if (!node.children || preservesTypeMatchedTable) {
@@ -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, isOraclePseudoColumnName, isOracleSystemValueName } from "@/lib/sql/sqlCompletion";
import { getSqlCompletionContext, isOraclePseudoColumnName, 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)));
@@ -601,7 +603,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) => ({
@@ -610,6 +618,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);
+79 -14
View File
@@ -1461,6 +1461,7 @@ export interface SqlCompletionObject {
parentName?: string;
dataType?: string;
signature?: string;
routineId?: string;
comment?: string | null;
applyName?: string;
boost?: number;
@@ -1555,11 +1556,13 @@ export type SqlCompletionContextKind = "table" | "schema" | "catalog" | "routine
export interface SqlCompletionContext {
prefix: string;
prefixQuoted?: boolean;
replacementRange?: { start: number; end: number };
preferredValueKeywords?: string[];
localVariables?: string[];
qualifier?: string;
qualifierParts?: string[];
qualifierQuoted?: boolean[];
suggestTables: boolean;
suggestColumns: boolean;
suggestKeywords: boolean;
@@ -1574,6 +1577,8 @@ export interface SqlCompletionContext {
insertTable?: string;
insertDatabase?: string;
insertSchema?: string;
insertTableQuoted?: boolean;
insertSchemaQuoted?: boolean;
statementKind: SqlStatementKind;
tableTriggerWord?: string;
isGroupBy: boolean;
@@ -2796,6 +2801,8 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq
localVariables,
qualifier: insertInfo ? undefined : qualifier,
qualifierParts: insertInfo ? undefined : qualifierParts,
qualifierQuoted: insertInfo ? undefined : trailingIdentifier?.qualifierQuoted,
prefixQuoted: trailingIdentifier?.prefixQuoted,
suggestTables: insertInfo || dataTypeContext ? false : afterTableTrigger,
suggestColumns,
suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && !inCallRoutineContext,
@@ -2810,6 +2817,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),
@@ -3071,7 +3080,14 @@ function detectCompletionContextKind(options: {
return "keyword";
}
function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseType): { start: number; prefix: string; qualifier?: string; qualifierParts?: string[] } | null {
function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseType): { start: number; prefix: string; prefixQuoted?: boolean; qualifier?: string; qualifierParts?: string[]; qualifierQuoted?: boolean[] } | null {
if (databaseType === "oceanbase-oracle" && input.includes('"')) {
const tokens = tokenizeSqlSemantic(input, "oracle");
const tail = tokens[tokens.length - 1];
if (tail?.kind === "quoted_identifier" && tail.quote === '"' && tail.closed === false) {
return parseTrailingIdentifierContext(`${input}"`, databaseType);
}
}
if (/\s$/.test(input)) return null;
let i = input.length - 1;
while (i >= 0 && /\s/.test(input[i] ?? "")) i--;
@@ -3105,19 +3121,23 @@ function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseTy
if (parts.length >= 2 || endsWithDot) {
const qualifierParts = (endsWithDot ? parts : parts.slice(0, -1)).map((part) => completionLookupIdentifier(part, databaseType));
const prefixPart = endsWithDot ? "" : unquoteIdentifier(parts[parts.length - 1] ?? "");
const rawPrefix = endsWithDot ? "" : unquoteIdentifier(parts[parts.length - 1] ?? "");
const prefixPart = databaseType === "oceanbase-oracle" ? rawPrefix.replaceAll('""', '"') : rawPrefix;
const qualifierValue = qualifierParts.join(".");
return {
start,
prefix: prefixPart,
qualifier: qualifierValue || undefined,
qualifierParts: qualifierParts.length > 0 ? qualifierParts : undefined,
qualifierQuoted: (endsWithDot ? parts : parts.slice(0, -1)).map((part) => part.startsWith('"')),
prefixQuoted: !endsWithDot && parts[parts.length - 1]?.startsWith('"'),
};
}
return {
start,
prefix: unquoteIdentifier(parts[0] ?? ""),
prefix: databaseType === "oceanbase-oracle" ? unquoteIdentifier(parts[0] ?? "").replaceAll('""', '"') : unquoteIdentifier(parts[0] ?? ""),
prefixQuoted: parts[0]?.startsWith('"'),
};
}
@@ -3389,7 +3409,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, "''");
@@ -3400,12 +3420,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,
};
}
@@ -4773,18 +4796,19 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO
return objects
.filter((object) => {
if (object.type === "sequence") {
return allowOracleSequences && objectMatchesCompletionContext(object, context);
return allowOracleSequences && objectMatchesCompletionContext(object, context, databaseType, currentSchema);
}
return (!onlyProcedures || object.type === "procedure") && (!onlyFunctions || (object.type === "function" && object.name.toLowerCase().startsWith(context.prefix.toLowerCase()))) && objectMatchesCompletionContext(object, context);
return (!onlyProcedures || object.type === "procedure") && (!onlyFunctions || (object.type === "function" && object.name.toLowerCase().startsWith(context.prefix.toLowerCase()))) && objectMatchesCompletionContext(object, context, databaseType, currentSchema);
})
.map((object) => {
const qualifiedByContext = objectIsQualifiedByContext(object, context);
const objectInCurrentSchema = !!currentSchema && !!object.schema && normalizeIdentifierPart(object.schema) === normalizeIdentifierPart(currentSchema);
const qualifiedByContext = objectIsQualifiedByContext(object, context, databaseType, currentSchema);
const objectInCurrentSchema = !!currentSchema && !!object.schema && (databaseType === "oceanbase-oracle" ? object.schema === currentSchema : normalizeIdentifierPart(object.schema) === normalizeIdentifierPart(currentSchema));
const suppliedApplyName = object.applyName ? quoteCompletionRoutineName(object.applyName, dialect) : undefined;
const oceanBaseApplyName = databaseType === "oceanbase-oracle" ? [...(object.schema && !objectInCurrentSchema ? [object.schema] : []), ...(object.parentName ? [object.parentName] : []), object.name].map((part) => quoteCompletionRoutineIdentifier(part, dialect)).join(".") : undefined;
const applyName =
qualifiedByContext || (context.qualifier && object.schema?.toLowerCase() === context.qualifier.toLowerCase())
qualifiedByContext || (context.qualifier && (databaseType === "oceanbase-oracle" ? object.schema === oceanBaseRoutineQualifierParts(context)[0] : object.schema?.toLowerCase() === context.qualifier.toLowerCase()))
? quoteCompletionRoutineIdentifier(object.name, dialect)
: (suppliedApplyName ?? (object.schema && !objectInCurrentSchema ? `${quoteCompletionRoutineIdentifier(object.schema, dialect)}.${quoteCompletionRoutineIdentifier(object.name, dialect)}` : quoteCompletionRoutineIdentifier(object.name, dialect)));
: (oceanBaseApplyName ?? suppliedApplyName ?? (object.schema && !objectInCurrentSchema ? `${quoteCompletionRoutineIdentifier(object.schema, dialect)}.${quoteCompletionRoutineIdentifier(object.name, dialect)}` : quoteCompletionRoutineIdentifier(object.name, dialect)));
const locationDetail = object.type === "trigger" && object.parentName ? `trigger on ${object.parentName}` : object.parentName ? `${object.type} in ${object.parentName}` : object.schema ? `${object.type} in ${object.schema}` : object.type;
const signature = object.signature?.trim();
const detail = [locationDetail, signature ? `(${signature})` : undefined, object.dataType ? `[${object.dataType}]` : undefined].filter(Boolean).join(" ");
@@ -4796,9 +4820,9 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO
type: object.type === "sequence" ? ("variable" as const) : ("function" as const),
detail,
info: buildRoutineInfo(object),
apply: object.type === "trigger" || object.type === "package" || object.type === "sequence" ? applyName : buildRoutineApply(applyName, object.signature, includeParams),
apply: object.type === "trigger" || object.type === "package" || object.type === "sequence" || (databaseType === "oceanbase-oracle" && object.signature == null) ? applyName : buildRoutineApply(applyName, object.signature, includeParams),
boost: computeBoost(object.name, context.prefix) + typeBoost + schemaBoost,
dedupeKey: signature ? `${baseDedupeKey ?? object.name}(${signature})` : baseDedupeKey,
dedupeKey: object.routineId ? JSON.stringify([applyName, object.routineId]) : signature ? `${baseDedupeKey ?? object.name}(${signature})` : baseDedupeKey,
// Preserve exact routine matches before the capped candidate list is truncated.
exactMatch: !!context.prefix && object.name.toLowerCase() === context.prefix.toLowerCase(),
};
@@ -4923,8 +4947,17 @@ function buildOracleSequencePseudoColumnItems(prefix: string, keywordCase?: SqlK
});
}
function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCompletionContext): boolean {
function oceanBaseRoutineQualifierParts(context: SqlCompletionContext): string[] {
return (context.qualifierParts ?? context.qualifier?.split(".") ?? []).map((part, index) => (context.qualifierQuoted?.[index] ? part.replaceAll('""', '"') : part.toUpperCase()));
}
function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCompletionContext, databaseType?: DatabaseType, currentSchema?: string): boolean {
if (!context.qualifier || !object.parentName) return false;
if (databaseType === "oceanbase-oracle") {
const parts = oceanBaseRoutineQualifierParts(context);
const owner = object.parentSchema ?? object.schema;
return object.parentName === parts[parts.length - 1] && (parts.length === 1 ? !!currentSchema && owner === currentSchema : owner === parts[parts.length - 2]);
}
const qualifier = context.qualifier.toLowerCase();
const qualifierParts = qualifier.split(".").filter(Boolean);
const qualifierSchema = qualifierParts.length > 1 ? qualifierParts[qualifierParts.length - 2] : undefined;
@@ -4932,8 +4965,14 @@ function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCom
return object.parentName.toLowerCase() === qualifier || (!!qualifierPackage && object.parentName.toLowerCase() === qualifierPackage && (!qualifierSchema || !object.parentSchema || object.parentSchema.toLowerCase() === qualifierSchema));
}
function objectMatchesCompletionContext(object: SqlCompletionObject, context: SqlCompletionContext): boolean {
function objectMatchesCompletionContext(object: SqlCompletionObject, context: SqlCompletionContext, databaseType?: DatabaseType, currentSchema?: string): boolean {
if (context.oracleTableFunctionContext && object.type !== "function") return false;
if (databaseType === "oceanbase-oracle") {
const prefixMatches = context.prefixQuoted ? object.name.startsWith(context.prefix) : matchesPrefix(object.name, context.prefix);
if (!context.qualifier) return prefixMatches;
const parts = oceanBaseRoutineQualifierParts(context);
return prefixMatches && (object.parentName ? objectIsQualifiedByContext(object, context, databaseType, currentSchema) : parts.length === 1 && object.schema === parts[0]);
}
if (object.type === "sequence") {
if (context.qualifier) {
const qualifier = context.qualifier.toLowerCase();
@@ -5543,7 +5582,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;
@@ -5681,6 +5723,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 });
@@ -5755,7 +5799,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);
@@ -5766,6 +5829,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);
@@ -10,14 +10,39 @@ export interface ObjectBrowserRowsLoadGuard {
start: (scope: ObjectBrowserRowsCacheScope) => ObjectBrowserRowsLoadHandle;
isEpochCurrent: (epoch: number) => boolean;
isCurrent: (handle: ObjectBrowserRowsLoadHandle) => boolean;
wait: <T>(epoch: number, promise: Promise<T>, timeoutMs: number) => Promise<T>;
}
export function createObjectBrowserRowsLoadGuard(): ObjectBrowserRowsLoadGuard {
let epoch = 0;
const pending = new Set<() => void>();
const invalidate = () => {
epoch++;
for (const cancel of pending) cancel();
return epoch;
};
return {
invalidate: () => ++epoch,
start: (scope) => ({ epoch: ++epoch, scope: Object.freeze({ ...scope }) }),
invalidate,
start: (scope) => ({ epoch: invalidate(), scope: Object.freeze({ ...scope }) }),
isEpochCurrent: (capturedEpoch) => capturedEpoch === epoch,
isCurrent: (handle) => handle.epoch === epoch,
async wait(capturedEpoch, promise, timeoutMs) {
let timer: ReturnType<typeof setTimeout> | undefined;
let cancel!: () => void;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
cancel = () => reject(new Error("Metadata waiting cancelled locally."));
pending.add(cancel);
if (capturedEpoch !== epoch) cancel();
else timer = setTimeout(() => reject(new Error(`Metadata loading timed out after ${Math.ceil(timeoutMs / 1000)}s.`)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
pending.delete(cancel);
}
},
};
}
@@ -6,6 +6,8 @@
* epoch; in-flight requests capture the epoch at start and compare before
* writing results.
*/
import { waitForMetadata } from "@/lib/metadata/metadataLoadBudget";
export interface SidePanelRequestGuard {
/** Bump the epoch, invalidating all previously captured epochs. */
bump: () => void;
@@ -17,17 +19,30 @@ export interface SidePanelRequestGuard {
isStale: (capturedEpoch: number) => boolean;
/** Returns true if the captured epoch is still current (request is fresh). */
isFresh: (capturedEpoch: number) => boolean;
wait: <T>(capturedEpoch: number, promise: Promise<T>, timeoutMs: number) => Promise<T>;
}
export function createSidePanelRequestGuard(): SidePanelRequestGuard {
let epoch = 0;
let controller = new AbortController();
const start = () => {
epoch++;
controller.abort(new Error("Metadata waiting cancelled locally."));
controller = new AbortController();
return epoch;
};
return {
bump: () => {
epoch++;
},
start: () => ++epoch,
bump: start,
start,
capture: () => epoch,
isStale: (capturedEpoch: number) => capturedEpoch !== epoch,
isFresh: (capturedEpoch: number) => capturedEpoch === epoch,
wait: (capturedEpoch, promise, timeoutMs) => {
if (capturedEpoch !== epoch) {
void promise.catch(() => undefined);
return Promise.reject(new Error("Metadata waiting cancelled locally."));
}
return waitForMetadata(promise, timeoutMs, controller.signal);
},
};
}
@@ -177,6 +177,16 @@ function deferred<T>() {
}
describe("connectionStore completion assistant", () => {
it.each([["routine", "sequence"], ["function", "sequence"], ["sequence"]] as const)("preserves requested OceanBase sequence candidates for %j", async (...objectKinds) => {
const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [{ name: "ORDER_SEQ", kind: "sequence", schema: "APP" }], incomplete: false, fallback_used: false });
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [oceanBaseOracleConnection()];
store.connectedIds.add("oceanbase-oracle-1");
expect(await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "ORDER", 20, "APP", undefined, false, "APP", [...objectKinds])).toEqual([expect.objectContaining({ name: "ORDER_SEQ", type: "sequence", schema: "APP" })]);
});
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
@@ -784,6 +794,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: [],
@@ -1600,6 +1625,101 @@ describe("connectionStore completion assistant", () => {
expect(columns).toEqual([expect.objectContaining({ name: "EXPLICIT_ID", schema: "REPORTING" })]);
});
it("filters OceanBase routines on the server and preserves case-distinct names", async () => {
const completionAssistantSearch = vi.fn().mockResolvedValue({
candidates: [
{ name: "CALC", kind: "procedure", schema: "APP", data_type: "PROCEDURE" },
{ name: "Calc", kind: "function", schema: "APP", data_type: "FUNCTION" },
],
incomplete: false,
fallback_used: false,
});
const listCompletionObjects = vi.fn();
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch, listCompletionObjects }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [oceanBaseOracleConnection()];
store.connectedIds.add("oceanbase-oracle-1");
const objects = await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "CA", 20, "APP");
expect(objects.map((object) => object.name)).toEqual(["CALC", "Calc"]);
expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ mask: "CA", parent_schema: "APP", max_results: 20, object_kinds: ["routine"] }));
expect(listCompletionObjects).not.toHaveBeenCalled();
expect((await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "Ca", 20, "APP", undefined, false, "APP", ["function"], true)).map((object) => object.name)).toEqual(["Calc"]);
});
it("retains OceanBase legacy-agent fallback without treating an empty search as unsupported", async () => {
const completionAssistantSearch = vi.fn().mockRejectedValueOnce(new Error("OceanBase agent does not support filtered routine completion")).mockResolvedValue({ candidates: [], incomplete: false, fallback_used: false });
const listCompletionObjects = vi.fn().mockResolvedValue([{ name: "P", object_type: "PROCEDURE", schema: "APP" }]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch, listCompletionObjects }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [oceanBaseOracleConnection()];
store.connectedIds.add("oceanbase-oracle-1");
expect((await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "P", 20, "APP")).map((object) => object.name)).toEqual(["P"]);
expect(await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "ZZ", 20, "APP")).toEqual([]);
expect(listCompletionObjects).toHaveBeenCalledTimes(1);
});
it("preserves OceanBase package overload identities and isolates quoted package caches", async () => {
const completionAssistantSearch = vi.fn(async (request: { parent_name: string; parent_schema: string }) => ({
candidates: [1, 2].map((id) => ({ name: "RUN", kind: "procedure", schema: request.parent_schema, parent_schema: request.parent_schema, parent_name: request.parent_name, routine_id: `${request.parent_name}:${id}`, signature: id === 1 ? "" : null })),
incomplete: false,
fallback_used: false,
}));
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [oceanBaseOracleConnection()];
store.connectedIds.add("oceanbase-oracle-1");
const upper = await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "R", 20, "APP", "PKG");
const mixed = await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "R", 20, "APP", "Pkg");
expect(upper.map((object) => [object.routineId, object.signature])).toEqual([
["PKG:1", ""],
["PKG:2", undefined],
]);
expect(mixed.map((object) => object.routineId)).toEqual(["Pkg:1", "Pkg:2"]);
expect(completionAssistantSearch).toHaveBeenCalledTimes(2);
expect(await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "R", 20, "APP", "PKG")).toEqual(upper);
expect(completionAssistantSearch).toHaveBeenCalledTimes(2);
});
it("does not turn an OceanBase package query failure into standalone routines", async () => {
const completionAssistantSearch = vi.fn().mockRejectedValue(new Error("package dictionary permission denied"));
const listCompletionObjects = vi.fn();
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch, listCompletionObjects }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [oceanBaseOracleConnection()];
store.connectedIds.add("oceanbase-oracle-1");
await expect(store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "R", 20, "APP", "PKG")).rejects.toThrow("package dictionary permission denied");
expect(listCompletionObjects).not.toHaveBeenCalled();
});
it("does not restore invalidated OceanBase routine results or their local index", async () => {
const stale = deferred<{ candidates: Array<{ name: string; kind: string; schema: string }>; incomplete: boolean; fallback_used: boolean }>();
const completionAssistantSearch = vi
.fn()
.mockReturnValueOnce(stale.promise)
.mockResolvedValue({ candidates: [{ name: "P_NEW", kind: "procedure", schema: "APP" }], incomplete: false, fallback_used: false });
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [oceanBaseOracleConnection()];
store.connectedIds.add("oceanbase-oracle-1");
const pending = store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "P", 20, "APP");
await vi.waitFor(() => expect(completionAssistantSearch).toHaveBeenCalledTimes(1));
store.invalidateCompletionTableCache("oceanbase-oracle-1", "OBORCL", "P_OLD", "APP");
await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "P", 20, "APP");
stale.resolve({ candidates: [{ name: "P_OLD", kind: "procedure", schema: "APP" }], incomplete: false, fallback_used: false });
expect((await pending).map((object) => object.name)).toEqual(["P_NEW"]);
expect(store.lookupLocalCompletionObjects("oceanbase-oracle-1", "OBORCL", "P", 20, "APP").map((object) => object.name)).toEqual(["P_NEW"]);
});
it("maps Oracle package members without scanning every schema", async () => {
const completionAssistantSearch = vi.fn().mockResolvedValue({
candidates: [{ name: "CALCULATE_BONUS", kind: "function", schema: "HR", parent_schema: "HR", parent_name: "PAYROLL", data_type: "FUNCTION" }],
@@ -0,0 +1,224 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { filterSidebarTree } from "@/lib/sidebar/sidebarSearchTree";
import type { ConnectionConfig, ObjectInfo, TreeNode } from "@/types/database";
const connection = { id: "ob-11418-sidebar", name: "OceanBase Oracle", db_type: "oceanbase-oracle", username: "APP", database: "APP" } as ConnectionConfig;
const procedure = (name: string): ObjectInfo => ({ name, object_type: "PROCEDURE", schema: "APP", comment: "needle" });
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
const data = new Map<string, string>();
vi.stubGlobal("localStorage", { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => data.set(key, value), removeItem: (key: string) => data.delete(key) });
setActivePinia(createPinia());
});
async function setup(listObjects: ReturnType<typeof vi.fn>, listTables?: ReturnType<typeof vi.fn>) {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listObjects,
listTables: listTables ?? vi.fn().mockResolvedValue([]),
listInstalledAgents: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped";
useSettingsStore().desktopSettings.sidebar_table_page_size = 2;
const group: TreeNode = {
id: `${connection.id}:APP:APP:${listTables ? "__tables" : "__procedures"}`,
label: listTables ? "tree.tables" : "tree.procedures",
type: listTables ? "group-tables" : "group-procedures",
connectionId: connection.id,
database: "APP",
schema: "APP",
isExpanded: true,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [
{
id: `${connection.id}:APP`,
label: "APP",
type: "database",
connectionId: connection.id,
database: "APP",
isExpanded: true,
children: [{ id: `${connection.id}:APP:APP`, label: "APP", type: "schema", connectionId: connection.id, database: "APP", schema: "APP", isExpanded: true, children: [group] }],
},
],
},
];
return { store, group };
}
function findMore(nodes: TreeNode[]): TreeNode | undefined {
for (const node of nodes) {
if (node.type === "load-more") return node;
const child = node.children && findMore(node.children);
if (child) return child;
}
}
describe("OceanBase Oracle sidebar paging (#11418)", () => {
it("uses one bounded object stream in simple mode, retaining a package and its body across pages", async () => {
const objects: ObjectInfo[] = [
{ name: "A_TABLE", schema: "APP", object_type: "TABLE", comment: "table comment" },
{ name: "SHARED", schema: "APP", object_type: "PACKAGE" },
{ name: "SHARED", schema: "APP", object_type: "PACKAGE_BODY" },
];
const listObjects = vi.fn().mockResolvedValueOnce(objects).mockResolvedValueOnce([objects[2]]);
const { store } = await setup(listObjects);
const { useSettingsStore } = await import("@/stores/settingsStore");
useSettingsStore().editorSettings.sidebarObjectDisplay = "simple";
const schema = store.treeNodes[0].children![0].children![0];
schema.children = [];
await store.loadTables(connection.id, "APP", "APP", { force: true });
expect(listObjects).toHaveBeenCalledTimes(1);
expect(listObjects.mock.calls[0]?.[3]).toEqual(expect.arrayContaining(["TABLE", "PACKAGE", "PACKAGE_BODY"]));
expect(listObjects.mock.calls[0]?.slice(5, 7)).toEqual([3, 0]);
expect(schema.children?.map((node) => node.type)).toContain("package");
expect(schema.children?.map((node) => node.type)).not.toContain("package-body");
await store.loadMoreObjectGroupChildren(schema.children!.at(-1)!);
expect(listObjects).toHaveBeenCalledTimes(2);
expect(listObjects.mock.calls[1]?.[3]).toEqual(listObjects.mock.calls[0]?.[3]);
expect(listObjects.mock.calls[1]?.slice(5, 7)).toEqual([3, 2]);
expect(
schema.children
?.filter((node) => node.label === "SHARED")
.map((node) => node.type)
.sort(),
).toEqual(["package", "package-body"]);
});
it("pages globally searched tables using their captured filter", async () => {
const tables = ["T_A", "T_B", "T_C"].map((name) => ({ name, table_type: "TABLE", comment: "needle" }));
const listTables = vi.fn().mockResolvedValueOnce(tables).mockResolvedValueOnce([tables[2]]);
const { store, group } = await setup(vi.fn(), listTables);
store.sidebarSearchQuery = "needle";
await store.loadObjectGroupChildren(group, { force: true });
expect(listTables.mock.calls[0]?.slice(3, 7)).toEqual(["needle", 3, 0, ["TABLE"]]);
const more = findMore(filterSidebarTree(store.treeNodes, "needle", new Set()));
expect(more).toBeDefined();
await store.loadMoreObjectGroupChildren(more!);
expect(listTables.mock.calls[1]?.slice(3, 7)).toEqual(["needle", 3, 2, ["TABLE"]]);
expect(group.children?.map((node) => node.label)).toEqual(["T_A", "T_B", "T_C"]);
});
it("keeps simple-mode table-scoped searches table-only across pages", async () => {
const objects = ["T_A", "T_B", "T_C"].map((name) => ({ name, schema: "APP", object_type: "TABLE", comment: "needle" }));
const listObjects = vi.fn().mockResolvedValueOnce(objects).mockResolvedValueOnce([objects[2]]);
const { store } = await setup(listObjects);
const { useSettingsStore } = await import("@/stores/settingsStore");
useSettingsStore().editorSettings.sidebarObjectDisplay = "simple";
const schema = store.treeNodes[0].children![0].children![0];
schema.children = [];
store.setSidebarTableSearchQuery(schema.id, "needle");
await store.refreshSidebarTableSearch(schema.id);
expect(listObjects.mock.calls[0]?.slice(3, 7)).toEqual([["TABLE"], "needle", 3, 0]);
await store.loadMoreObjectGroupChildren(schema.children!.at(-1)!, { searchFilter: "needle" });
expect(listObjects.mock.calls[1]?.slice(3, 7)).toEqual([["TABLE"], "needle", 3, 2]);
});
it("keeps filtered object pages bounded and exposes their next page in global search", async () => {
const listObjects = vi
.fn()
.mockResolvedValueOnce([procedure("P_A"), procedure("P_B"), procedure("P_C")])
.mockResolvedValueOnce([procedure("P_C")]);
const { store, group } = await setup(listObjects);
store.sidebarSearchQuery = "needle";
await store.loadObjectGroupChildren(group, { force: true, searchFilter: "needle", expectedSidebarSearchQuery: "needle" });
expect(listObjects).toHaveBeenNthCalledWith(1, connection.id, "APP", "APP", ["PROCEDURE"], "needle", 3, 0);
expect(group.children?.map((node) => node.label)).toEqual(["P_A", "P_B", "tree.loadMore"]);
const more = findMore(filterSidebarTree(store.treeNodes, "needle", new Set()));
expect(more).toBeDefined();
expect(findMore(filterSidebarTree(store.treeNodes, "needle", new Set(), new Set(["table"])))).toBeUndefined();
await store.loadMoreObjectGroupChildren(more!);
expect(listObjects).toHaveBeenNthCalledWith(2, connection.id, "APP", "APP", ["PROCEDURE"], "needle", 3, 2);
expect(group.children?.map((node) => node.label)).toEqual(["P_A", "P_B", "P_C"]);
});
it("rejects a captured search page before sending when the filter changed", async () => {
const listObjects = vi.fn().mockResolvedValue([procedure("P_A"), procedure("P_B"), procedure("P_C")]);
const { store, group } = await setup(listObjects);
store.sidebarSearchQuery = "needle";
await store.loadObjectGroupChildren(group, { force: true });
const more = group.children!.at(-1)!;
store.sidebarSearchQuery = "other";
expect(findMore(filterSidebarTree(store.treeNodes, "other", new Set()))).toBeUndefined();
await store.loadMoreObjectGroupChildren(more);
expect(listObjects).toHaveBeenCalledTimes(1);
});
it("keeps paging when a matching schema makes the object filter empty", async () => {
const listObjects = vi
.fn()
.mockResolvedValueOnce([procedure("P_A"), procedure("P_B"), procedure("P_C")])
.mockResolvedValueOnce([procedure("P_C")]);
const { store, group } = await setup(listObjects);
store.sidebarSearchQuery = "APP";
await store.loadObjectGroupChildren(group, { force: true, searchFilter: "", expectedSidebarSearchQuery: "APP", allowGlobalSearchMismatch: true });
await store.loadMoreObjectGroupChildren(group.children!.at(-1)!);
expect(listObjects).toHaveBeenNthCalledWith(2, connection.id, "APP", "APP", ["PROCEDURE"], undefined, 3, 2);
expect(group.children?.map((node) => node.label)).toEqual(["P_A", "P_B", "P_C"]);
});
it.each(["filter", "cancel", "schema", "refresh"] as const)("rejects a delayed page after %s invalidates its context", async (change) => {
let resolveOld!: (objects: ObjectInfo[]) => void;
const pending = new Promise<ObjectInfo[]>((resolve) => {
resolveOld = resolve;
});
const listObjects = vi
.fn()
.mockResolvedValueOnce([procedure("P_A"), procedure("P_B"), procedure("P_C")])
.mockReturnValueOnce(pending)
.mockResolvedValue([procedure("P_FRESH")]);
const { store, group } = await setup(listObjects);
store.sidebarSearchQuery = "needle";
await store.loadObjectGroupChildren(group, { force: true });
const loading = store.loadMoreObjectGroupChildren(group.children!.at(-1)!);
await vi.waitFor(() => expect(listObjects).toHaveBeenCalledTimes(2));
if (change === "filter") store.sidebarSearchQuery = "other";
else if (change === "cancel") store.cancelTreeNodeLoad(group.id);
else if (change === "schema") store.treeNodes[0].children = [{ id: "OTHER", label: "OTHER", type: "schema", schema: "OTHER", children: [] }];
else await store.loadObjectGroupChildren(group, { force: true });
resolveOld([procedure("P_C")]);
await loading;
expect(group.children?.some((node) => node.label === "P_C")).toBe(false);
if (change === "refresh") expect(group.children?.map((node) => node.label)).toEqual(["P_FRESH"]);
});
it("does not surface a delayed page error after the search changed", async () => {
let rejectOld!: (error: Error) => void;
const pending = new Promise<ObjectInfo[]>((_resolve, reject) => {
rejectOld = reject;
});
const listObjects = vi
.fn()
.mockResolvedValueOnce([procedure("P_A"), procedure("P_B"), procedure("P_C")])
.mockReturnValueOnce(pending);
const { store, group } = await setup(listObjects);
store.sidebarSearchQuery = "needle";
await store.loadObjectGroupChildren(group, { force: true });
const loading = store.loadMoreObjectGroupChildren(group.children!.at(-1)!);
await vi.waitFor(() => expect(listObjects).toHaveBeenCalledTimes(2));
store.sidebarSearchQuery = "other";
rejectOld(new Error("old page failed"));
await expect(loading).resolves.toBeUndefined();
});
});
@@ -1615,6 +1615,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([
+147 -73
View File
@@ -3,7 +3,7 @@ import { cancelSchemaDiffTasksForConnection } from "@/lib/schema/schemaDiffCance
import type { SqlFilePreview } from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
import { ref, computed, watch, markRaw } from "vue";
import { ref, computed, watch, markRaw, reactive } from "vue";
import { DEFAULT_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits";
import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents";
import type {
@@ -196,6 +196,7 @@ import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metad
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
import { cancelObjectDdlLoadsForConnection, cancelObjectDdlLoadsForDatabase, invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache";
import { cancelObjectMetadataLoadsForConnection, cancelObjectMetadataLoadsForDatabase } from "@/lib/metadata/objectMetadataCache";
import { metadataLoadBudgetMs } from "@/lib/metadata/metadataLoadBudget";
import { clearMetadataRuntimeCacheForConnection, clearMetadataRuntimeCacheForDatabase } from "@/lib/metadata/metadataRuntimeCache";
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
@@ -218,8 +219,6 @@ const CONNECTION_HEALTH_CHECK_TIMEOUT_MS = 5000;
/** How long a successful driver/pool warm-up is trusted before the next tab
* activation warms the same pool target again. */
const CONNECTION_PREWARM_TTL_MS = 30_000;
const METADATA_LOAD_MIN_TIMEOUT_MS = 15_000;
const METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS = 60_000;
const DISCONNECT_REQUEST_TIMEOUT_MS = 5_000;
const DEFAULT_KEEPALIVE_INTERVAL_SECS = 30;
const METADATA_LIST_PAGE_CACHE_TTL_MS = 30_000;
@@ -1455,10 +1454,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
function metadataLoadTimeoutMs(config?: ConnectionConfig): number {
const queryTimeoutSecs = Number(config?.query_timeout_secs);
if (queryTimeoutSecs === 0) return METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS;
const boundedTimeoutSecs = Number.isFinite(queryTimeoutSecs) && queryTimeoutSecs > 0 ? queryTimeoutSecs + 5 : 35;
return Math.max(METADATA_LOAD_MIN_TIMEOUT_MS, boundedTimeoutSecs * 1000);
return metadataLoadBudgetMs(config?.query_timeout_secs);
}
async function withConnectionHealthTimeout(connectionId: string, promise: Promise<void>): Promise<void> {
@@ -2554,8 +2550,8 @@ export const useConnectionStore = defineStore("connection", () => {
// 查询代次时取两者较新者,disconnect 只 bump 连接级、closeDatabaseConnection
// 只 bump 数据库级,因此作用域天然隔离。
let metadataGenerationCounter = 0;
const metadataGenerationByConnection = new Map<string, number>();
const metadataGenerationByDatabase = new Map<string, number>();
const metadataGenerationByConnection = reactive(new Map<string, number>());
const metadataGenerationByDatabase = reactive(new Map<string, number>());
function metadataGenerationFor(connectionId: string, database?: string): number {
const connectionGeneration = metadataGenerationByConnection.get(connectionId) ?? 0;
@@ -2705,7 +2701,7 @@ export const useConnectionStore = defineStore("connection", () => {
return fetchPage(0, offset + pageSize);
}
function buildLoadMoreNode(parent: TreeNode, offset: number, pageSize: number, anchor?: string): TreeNode {
function buildLoadMoreNode(parent: TreeNode, offset: number, pageSize: number, anchor?: string, searchFilter?: string): TreeNode {
return {
id: `${parent.id}:__load_more:${offset}`,
label: "tree.loadMore",
@@ -2718,6 +2714,7 @@ export const useConnectionStore = defineStore("connection", () => {
parentId: parent.id,
offset,
pageSize,
...(searchFilter !== undefined ? { searchFilter, searchQuery: sidebarSearchQuery.value || "" } : {}),
...(anchor ? { anchor } : {}),
},
};
@@ -2837,7 +2834,7 @@ export const useConnectionStore = defineStore("connection", () => {
// Table-scoped search must page through the full ordered result set, just
// like unfiltered loads, instead of silently stopping at the first budget
// window. Global sidebar search keeps the bounded single-shot fetch.
const paginate = options.pagedSearch || !searchFilter;
const paginate = options.pagedSearch || !searchFilter || getConfig(options.node.connectionId)?.db_type === "oceanbase-oracle";
const fetchLimit = paginate ? options.pageSize + 1 : SIDEBAR_TABLE_SEARCH_RESULT_BUDGET;
const fetchOffset = paginate ? options.offset : undefined;
const tables = await loadCachedMetadataListPage<TableInfo[]>(
@@ -2894,8 +2891,9 @@ export const useConnectionStore = defineStore("connection", () => {
catalog: options.node.catalog,
});
const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter);
const fetchLimit = searchFilter ? undefined : options.pageSize + 1;
const fetchOffset = searchFilter ? undefined : options.offset;
const paginate = !searchFilter || getConfig(options.node.connectionId)?.db_type === "oceanbase-oracle";
const fetchLimit = paginate ? options.pageSize + 1 : undefined;
const fetchOffset = paginate ? options.offset : undefined;
const objects = await loadCachedMetadataListPage<ObjectInfo[]>(
metadataListCacheScope({
kind: "object-list-page",
@@ -2916,7 +2914,7 @@ export const useConnectionStore = defineStore("connection", () => {
: api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset),
{ force: options.force },
);
const hasMore = searchFilter ? false : objects.length > options.pageSize;
const hasMore = paginate && objects.length > options.pageSize;
const pageObjects = hasMore ? objects.slice(0, options.pageSize) : objects;
const children = objectGroupChildrenFromObjects({
node: options.node,
@@ -2955,7 +2953,36 @@ export const useConnectionStore = defineStore("connection", () => {
nodeKind: "simple-tables",
});
const sourceRevision = tableListSourceRevision(options.connectionId);
const paginate = options.pagedSearch || !searchFilter;
if (getConfig(options.connectionId)?.db_type === "oceanbase-oracle" && !tableNameFilter) {
const objectTypes = (["TABLE", ...options.nonTableObjectTypes] as DatabaseObjectTreeKind[]).filter((type) => ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "SEQUENCE", "SYNONYM"].includes(type));
const objects = await loadCachedMetadataListPage<ObjectInfo[]>(
metadataListCacheScope({
kind: "object-list-page",
connectionId: options.connectionId,
database: options.database,
schema: options.querySchema,
nodeKind: "simple-objects",
objectTypes,
searchFilter,
limit: options.pageSize + 1,
offset: options.offset,
sidebarDisplayMode: "simple",
extra: tableNameFilterMetadataExtra(tableNameFilter, sourceRevision),
}),
() => api.listObjects(options.connectionId, options.database, options.querySchema, objectTypes, searchFilter, options.pageSize + 1, options.offset),
{ force: options.force },
);
const hasMore = objects.length > options.pageSize;
const pageObjects = objects.slice(0, options.pageSize);
const children = markPackageNodesExpandable(buildSimpleObjectTreeNodes({ nodeId: options.nodeId, connectionId: options.connectionId, database: options.database, schema: options.effectiveSchema, objects: pageObjects, databaseType: "oceanbase-oracle" }));
if (tableListSourceRevision(options.connectionId) === sourceRevision) {
const tables = pageObjects.filter((object) => object.object_type === "TABLE").map((object) => ({ name: object.name, table_type: "TABLE", comment: object.comment }));
indexCompletionTables(options.connectionId, options.database, options.effectiveSchema, tableInfosToCompletionTables(tables, options.effectiveSchema));
}
const anchor = (object: ObjectInfo | undefined) => (object ? JSON.stringify([object.schema ?? options.effectiveSchema, object.name, object.object_type, object.signature ?? ""]) : undefined);
return { children, objectCount: children.length, hasMore, nextOffset: options.offset + pageObjects.length, firstAnchor: anchor(objects[0]), nextAnchor: hasMore ? anchor(objects[options.pageSize]) : undefined };
}
const paginate = options.pagedSearch || !searchFilter || getConfig(options.connectionId)?.db_type === "oceanbase-oracle";
const fetchLimit = paginate ? options.pageSize + 1 : SIDEBAR_TABLE_SEARCH_RESULT_BUDGET;
const fetchOffset = paginate ? options.offset : undefined;
const tables = await loadCachedMetadataListPage<TableInfo[]>(
@@ -6623,14 +6650,15 @@ export const useConnectionStore = defineStore("connection", () => {
database,
querySchema,
effectiveSchema,
nonTableObjectTypes,
nonTableObjectTypes: isSidebarTableSearch ? [] : nonTableObjectTypes,
offset: 0,
pageSize,
searchFilter: options?.searchFilter === "" ? "" : searchFilter || undefined,
pagedSearch: isSidebarTableSearch,
force: options?.force,
});
children = page.hasMore && (!searchFilter || isSidebarTableSearch) ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize, page.nextAnchor), page.loadMoreParent) : page.children;
const globalPageSearch = config?.db_type === "oceanbase-oracle" && !isSidebarTableSearch ? searchFilter : undefined;
children = page.hasMore && (!searchFilter || isSidebarTableSearch || globalPageSearch !== undefined) ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize, page.nextAnchor, globalPageSearch), page.loadMoreParent) : page.children;
nextObjectCount = page.objectCount;
} else if (simpleObjectDisplay) {
// The synthetic public scope contains no tables. Avoid issuing a
@@ -6666,7 +6694,7 @@ export const useConnectionStore = defineStore("connection", () => {
const currentTargetNode = treeNodeLoadTarget(load);
if (!currentTargetNode) return;
currentTargetNode.isExpanded = true;
if (simpleObjectDisplay && !searchFilter && !isSidebarTableSearch && nonTableObjectTypes.length > 0) {
if (simpleObjectDisplay && (config?.db_type !== "oceanbase-oracle" || tableNameFilter) && !searchFilter && !isSidebarTableSearch && nonTableObjectTypes.length > 0) {
void loadSimpleSupplementalObjectChildren({
node: currentTargetNode,
nodeId,
@@ -6767,6 +6795,7 @@ export const useConnectionStore = defineStore("connection", () => {
catalog: node.catalog,
});
const isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
const globalPageSearch = config?.db_type === "oceanbase-oracle" && !isSidebarTableSearch ? searchFilter : undefined;
if (!options?.force && !searchFilter && !tableNameFilter) {
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
if (cached.hit) {
@@ -6792,7 +6821,8 @@ export const useConnectionStore = defineStore("connection", () => {
pagedSearch: isSidebarTableSearch,
force: options?.force,
});
children = page.hasMore && (!searchFilter || isSidebarTableSearch) ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, sidebarObjectGroupPageSize(), page.nextAnchor), page.loadMoreParent) : page.children;
children =
page.hasMore && (!searchFilter || isSidebarTableSearch || globalPageSearch !== undefined) ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, sidebarObjectGroupPageSize(), page.nextAnchor, globalPageSearch), page.loadMoreParent) : page.children;
nextObjectCount = page.objectCount;
} else {
const pageSize = sidebarObjectGroupPageSize();
@@ -6807,7 +6837,7 @@ export const useConnectionStore = defineStore("connection", () => {
searchFilter: searchFilter || undefined,
force: options?.force,
});
children = page.hasMore && !searchFilter ? [...page.children, buildLoadMoreNode(node, page.nextOffset, pageSize, page.nextAnchor)] : page.children;
children = page.hasMore && (!searchFilter || globalPageSearch !== undefined) ? [...page.children, buildLoadMoreNode(node, page.nextOffset, pageSize, page.nextAnchor, globalPageSearch)] : page.children;
nextObjectCount = page.objectCount;
}
if (isTreeLoadSearchChanged(searchFilter, options)) return;
@@ -6905,6 +6935,14 @@ export const useConnectionStore = defineStore("connection", () => {
const parentConnectionId = parent.connectionId;
const configForScope = getConfig(parentConnectionId);
const objectTypesForScope = objectTypesForGroupNode(parent.type);
const searchFilter = loadMore.searchFilter ?? options?.searchFilter;
const globalSearchQuery = sidebarSearchQuery.value || "";
const scopedSearchQuery = sidebarTableSearchQueries.value[parent.id]?.trim() || "";
const searchIsCurrent = () =>
(sidebarSearchQuery.value || "") === globalSearchQuery &&
(sidebarTableSearchQueries.value[parent.id]?.trim() || "") === scopedSearchQuery &&
(loadMore.searchQuery === undefined ? options?.searchFilter === undefined || scopedSearchQuery === (searchFilter || "") : globalSearchQuery === loadMore.searchQuery);
if (!searchIsCurrent()) return;
return runTreeMetadataLoad(
{
kind: "object-group-page",
@@ -6913,7 +6951,7 @@ export const useConnectionStore = defineStore("connection", () => {
schema: parent.schema,
nodeKind: parent.type,
objectTypes: objectTypesForScope,
searchFilter: options?.searchFilter,
searchFilter,
limit: loadMore.pageSize + 1,
offset: loadMore.offset,
sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay,
@@ -6926,6 +6964,7 @@ export const useConnectionStore = defineStore("connection", () => {
const parentEpoch = treeNodeLoads.observe(parent.id);
try {
await ensureConnected(parentConnectionId);
if (!searchIsCurrent()) return;
load = reclaimTreeNodeLoad(load, node);
if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") {
const parentDatabase = parent.database;
@@ -6940,26 +6979,26 @@ export const useConnectionStore = defineStore("connection", () => {
database: parentDatabase,
querySchema,
effectiveSchema,
nonTableObjectTypes: [],
nonTableObjectTypes: config?.db_type === "oceanbase-oracle" && loadMore.searchQuery !== undefined ? sidebarObjectTypesForScope(config, parentDatabase, parent.schema).filter((type) => type !== "TABLE") : [],
offset,
pageSize,
searchFilter: options?.searchFilter,
pagedSearch: !!options?.searchFilter,
searchFilter,
pagedSearch: !!searchFilter,
force: false,
}),
);
const targetParent = treeNodeLoadRelatedTarget(load, parent);
if (!targetParent || !parentEpoch.isCurrent()) return;
if (!targetParent || !parentEpoch.isCurrent() || !searchIsCurrent()) return;
const currentChildren = withoutTableTreeLoadMoreNodes(targetParent.children);
const mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase);
const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize, page.nextAnchor), page.loadMoreParent) : mergedChildren;
const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize, page.nextAnchor, loadMore.searchFilter), page.loadMoreParent) : mergedChildren;
targetParent.objectCount = mergedChildren.length;
setChildren(targetParent, nextChildren);
if (!options?.searchFilter) {
if (!searchFilter) {
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v9", parent.schema)), nextChildren);
}
// 该分支只服务 simple 库/模式表列表;搜索分页结果不是全量,不能作为成员依据。
if (!page.hasMore && !options?.searchFilter) pruneTableVGroupStaleMembers(targetParent, nextChildren, true);
if (!page.hasMore && !searchFilter) pruneTableVGroupStaleMembers(targetParent, nextChildren, true);
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
return;
@@ -6986,16 +7025,16 @@ export const useConnectionStore = defineStore("connection", () => {
objectTypes,
offset,
pageSize,
searchFilter: options?.searchFilter,
pagedSearch: !!options?.searchFilter,
searchFilter,
pagedSearch: !!searchFilter,
force: false,
}),
);
const targetParent = treeNodeLoadRelatedTarget(load, parent);
if (!targetParent || !parentEpoch.isCurrent()) return;
if (!targetParent || !parentEpoch.isCurrent() || !searchIsCurrent()) return;
const currentChildren = withoutTableTreeLoadMoreNodes(targetParent.children);
mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase);
nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize, page.nextAnchor), page.loadMoreParent) : mergedChildren;
nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize, page.nextAnchor, loadMore.searchFilter), page.loadMoreParent) : mergedChildren;
} else {
const page = await loadTablePageCheckingAnchor(loadMore.anchor, loadMore.offset, loadMore.pageSize, (offset, pageSize) =>
loadPagedObjectGroupChildren({
@@ -7006,18 +7045,18 @@ export const useConnectionStore = defineStore("connection", () => {
objectTypes,
offset,
pageSize,
searchFilter: options?.searchFilter,
searchFilter,
force: false,
}),
);
const targetParent = treeNodeLoadRelatedTarget(load, parent);
if (!targetParent || !parentEpoch.isCurrent()) return;
if (!targetParent || !parentEpoch.isCurrent() || !searchIsCurrent()) return;
const currentChildren = withoutLoadMoreNodes(targetParent.children);
mergedChildren = mergeLocatedTreeChildren(targetParent, currentChildren, page.children, parentConnectionId, parentDatabase);
nextChildren = page.hasMore ? [...mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize, page.nextAnchor)] : mergedChildren;
nextChildren = page.hasMore ? [...mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize, page.nextAnchor, loadMore.searchFilter)] : mergedChildren;
targetParent.objectCount = mergedChildren.length;
setChildren(targetParent, nextChildren);
if (!options?.searchFilter) {
if (!searchFilter) {
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
// procedures/triggers 等对象组在此分支提前 return,回收必须在 return 前;
// 非分组容器由 prune 内部的类别防护挡下。
@@ -7028,10 +7067,10 @@ export const useConnectionStore = defineStore("connection", () => {
return;
}
const targetParent = treeNodeLoadRelatedTarget(load, parent);
if (!targetParent || !parentEpoch.isCurrent()) return;
if (!targetParent || !parentEpoch.isCurrent() || !searchIsCurrent()) return;
targetParent.objectCount = mergedChildren.length;
setChildren(targetParent, nextChildren);
if (!options?.searchFilter) {
if (!searchFilter) {
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
// 各分组容器按自身类别回收完整列表的失效成员;分页中间态(含
// load-more)由 prune 内部再挡一次。
@@ -7040,6 +7079,7 @@ export const useConnectionStore = defineStore("connection", () => {
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
} catch (e) {
if (!searchIsCurrent() || !parentEpoch.isCurrent()) return;
recordMetadataLoadError(parentConnectionId, e, load);
throw e;
} finally {
@@ -8205,6 +8245,8 @@ export const useConnectionStore = defineStore("connection", () => {
});
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
if (databaseType === "oceanbase-oracle" && response.fallback_used) throw new Error("OceanBase agent does not support package member completion; update the agent");
if (databaseType === "oceanbase-oracle" && response.incomplete) throw new Error("Package has more than 1000 members; use a member prefix in SQL completion to narrow the results");
setChildren(targetNode, buildPackageMemberNodes(targetNode, response.candidates, databaseType));
targetNode.isExpanded = true;
},
@@ -8298,7 +8340,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
const ORACLE_SYSTEM_COMPLETION_SCHEMAS = new Set(["SYS", "SYSTEM", "SYSMAN", "DBSNMP", "OUTLN", "XDB", "MDSYS", "CTXSYS", "WMSYS"]);
const FILTERED_ROUTINE_COMPLETION_DATABASES = new Set<DatabaseType>(["mysql", "postgres", "sqlserver", "oracle", "opengauss"]);
const FILTERED_ROUTINE_COMPLETION_DATABASES = new Set<DatabaseType>(["mysql", "postgres", "sqlserver", "oracle", "oceanbase-oracle", "opengauss"]);
function completionPreferredSchema(connectionId: string, preferredSchema?: string): string | undefined {
return preferredSchema?.trim() || getConfig(connectionId)?.username?.trim() || undefined;
@@ -8354,6 +8396,7 @@ export const useConnectionStore = defineStore("connection", () => {
parentName: candidate.parent_name ?? undefined,
dataType,
signature: candidate.signature ?? undefined,
routineId: candidate.routine_id ?? undefined,
comment: candidate.comment ?? null,
applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema),
boost: oracleMetadata ? completionCandidateSchemaBoost(candidate.schema, preferredSchema) : completionRoutineSchemaBoost(candidate.schema, preferredSchema),
@@ -8449,30 +8492,35 @@ export const useConnectionStore = defineStore("connection", () => {
objectKinds: CompletionAssistantObjectKind[],
caseSensitive: boolean,
matchMode: CompletionAssistantMatchMode = "prefix",
requestRevision = completionCacheRevision(connectionId, database),
): Promise<SqlCompletionObject[]> {
const databaseType = getConfig(connectionId)?.db_type;
const oracleAssistant = isOracleCompletionDatabase(databaseType);
const requestedSchema = schema?.trim() || currentSchema?.trim() || undefined;
const sequenceOnly = objectKinds.length === 1 && objectKinds[0] === "sequence";
const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, currentSchema) : requestedSchema || (!sequenceOnly && databaseType === "postgres" ? "public" : databaseType === "mysql" ? database : undefined);
const response = await completionAssistantSearch({
connection_id: connectionId,
database,
schema: oracleAssistant ? (preferredSchema ?? null) : (requestedSchema ?? null),
object_kinds: objectKinds,
mask: filter.trim(),
case_sensitive: caseSensitive,
max_results: limit ?? 200,
global_search: globalSearch,
parent_schema: globalSearch || sequenceOnly ? null : (schema ?? null),
parent_name: parentName ?? null,
match_mode: matchMode,
});
const response = await completionAssistantSearch(
{
connection_id: connectionId,
database,
schema: oracleAssistant ? (preferredSchema ?? null) : (requestedSchema ?? null),
object_kinds: objectKinds,
mask: filter.trim(),
case_sensitive: caseSensitive,
max_results: limit ?? 200,
global_search: globalSearch,
parent_schema: globalSearch || sequenceOnly ? null : (schema ?? null),
parent_name: parentName ?? null,
match_mode: matchMode,
},
requestRevision,
);
if (databaseType === "oceanbase-oracle" && response.fallback_used) throw new Error("OceanBase agent does not support filtered routine completion");
const objects = completionAssistantObjects(response.candidates, preferredSchema, oracleAssistant).map((object) => ({
...object,
applyName: databaseType === "sqlserver" && object.schema ? `${object.schema}.${object.name}` : object.applyName,
applyName: databaseType === "oceanbase-oracle" ? undefined : databaseType === "sqlserver" && object.schema ? `${object.schema}.${object.name}` : object.applyName,
}));
indexCompletionObjects(connectionId, database, schema, objects);
if (requestRevision === completionCacheRevision(connectionId, database)) indexCompletionObjects(connectionId, database, schema, objects);
return objects;
}
@@ -8637,6 +8685,7 @@ export const useConnectionStore = defineStore("connection", () => {
const scopes = schema ? (preferred ? [preferred] : []) : allScopes;
const ranked = scopes
.flatMap((entry) => entry?.objects ?? [])
.filter((object) => getConfig(connectionId)?.db_type !== "oceanbase-oracle" || !schema || object.schema === schema)
.map((object) => ({ object, score: objectMatchScore(object, filter, schema) }))
.filter((entry) => entry.score >= 0)
.sort((a, b) => b.score - a.score || a.object.name.localeCompare(b.object.name));
@@ -9220,47 +9269,68 @@ export const useConnectionStore = defineStore("connection", () => {
const cacheFilter = caseSensitive ? filter.trim() : normalizedFilter;
const databaseType = getConfig(connectionId)?.db_type;
const filteredRoutineAssistant = !!databaseType && FILTERED_ROUTINE_COMPLETION_DATABASES.has(databaseType) && (!!normalizedFilter || typeof limit === "number" || !!parentName || globalSearch);
const cacheKey = filteredRoutineAssistant
? `${connectionId}:${database}:${schema ?? ""}:${parentName ?? ""}:${cacheFilter}:${limit ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}:${[...objectKinds].sort().join(",")}:${caseSensitive ? "case-sensitive" : "case-insensitive"}`
: `${connectionId}:${database}:${schema ?? ""}`;
const cacheKey =
databaseType === "oceanbase-oracle" && filteredRoutineAssistant
? `${connectionId}:${database}:${JSON.stringify([schema, parentName, cacheFilter, limit, globalSearch, currentSchema, [...objectKinds].sort(), caseSensitive])}`
: filteredRoutineAssistant
? `${connectionId}:${database}:${schema ?? ""}:${parentName ?? ""}:${cacheFilter}:${limit ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}:${[...objectKinds].sort().join(",")}:${caseSensitive ? "case-sensitive" : "case-insensitive"}`
: `${connectionId}:${database}:${schema ?? ""}`;
if (!completionObjectsCache.value[cacheKey]) {
const requestRevision = completionCacheRevision(connectionId, database);
await withCompletionInFlight(
`${cacheKey}:objects`,
`${cacheKey}:objects:${requestRevision}`,
async () => {
await ensureConnected(connectionId);
let loadedObjects: SqlCompletionObject[];
if (filteredRoutineAssistant) {
try {
let assistantObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive);
let assistantObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive, "prefix", requestRevision);
if (shouldWidenCompletionMatch(filter, assistantObjects.length, limit)) {
try {
const widenedObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive, "contains");
const widenedObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive, "contains", requestRevision);
assistantObjects = [...assistantObjects, ...widenedObjects];
} catch {
// Keep the prefix matches when the widened lookup is unavailable.
}
}
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(assistantObjects);
} catch {
loadedObjects = dedupeCompletionObjects(assistantObjects);
} catch (error) {
if (requestRevision !== completionCacheRevision(connectionId, database)) return;
if (databaseType === "oceanbase-oracle" && parentName) throw error;
if (databaseType === "oceanbase-oracle" && !/does not support filtered routine completion|unknown method|method not found|not supported/i.test(String(error))) throw error;
if (objectKinds.length === 1 && objectKinds[0] === "sequence") {
completionObjectsCache.value[cacheKey] = [];
loadedObjects = [];
} else {
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, connectionDatabaseMetadataSchema(getConfig(connectionId), database, schema));
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null));
loadedObjects = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null));
}
}
} else {
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, connectionDatabaseMetadataSchema(getConfig(connectionId), database, schema));
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null));
loadedObjects = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null));
}
indexCompletionObjects(connectionId, database, schema, completionObjectsCache.value[cacheKey]);
if (requestRevision !== completionCacheRevision(connectionId, database)) return;
completionObjectsCache.value[cacheKey] = loadedObjects;
indexCompletionObjects(connectionId, database, schema, loadedObjects);
evictOldestCacheEntries(completionObjectsCache.value, COMPLETION_CACHE_MAX);
},
{ scope: completionLimiterScope(connectionId, database), kind: "objects" },
);
}
const objects = completionObjectsCache.value[cacheKey];
const filtered = normalizedFilter ? objects.filter((object) => fuzzyCompletionObjectMatch(object, normalizedFilter)) : objects;
const objects = completionObjectsCache.value[cacheKey] ?? [];
const filtered = objects.filter((object) => {
if (databaseType === "oceanbase-oracle") {
if (object.type === "sequence") {
if (!objectKinds.includes("sequence")) return false;
} else if (object.type !== "procedure" && object.type !== "function") return false;
else if (!objectKinds.includes("routine") && !objectKinds.includes(object.type)) return false;
if (schema && !globalSearch && object.schema !== schema) return false;
if (parentName && object.parentName !== parentName) return false;
if (caseSensitive) return object.name.includes(filter.trim());
}
return !normalizedFilter || fuzzyCompletionObjectMatch(object, normalizedFilter);
});
return typeof limit === "number" ? filtered.slice(0, limit) : filtered;
}
@@ -9325,7 +9395,7 @@ export const useConnectionStore = defineStore("connection", () => {
const seen = new Set<string>();
const deduped: SqlCompletionObject[] = [];
for (const object of objects) {
const key = `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}:${object.signature?.trim() ?? ""}`.toLowerCase();
const key = JSON.stringify([object.type, object.schema, object.name, object.parentName, object.routineId ?? object.signature?.trim()]);
if (seen.has(key)) continue;
seen.add(key);
deduped.push(object);
@@ -9343,7 +9413,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.
@@ -9365,7 +9435,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(
@@ -9399,7 +9471,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);
@@ -9470,7 +9544,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);
}
+11 -4
View File
@@ -3194,6 +3194,7 @@ export const useQueryStore = defineStore("query", () => {
const { ddl } = await loadObjectDdl(
{
connectionId,
queryTimeoutSecs: connectionStore.getConfig(connectionId)?.query_timeout_secs,
database,
schema: ddlViewer.schema || database,
tableName: ddlViewer.tableName,
@@ -6186,6 +6187,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
@@ -6243,7 +6245,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.
@@ -6275,7 +6280,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,
@@ -6285,6 +6290,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,
@@ -6296,7 +6302,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,
@@ -6304,7 +6311,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,
+6
View File
@@ -83,6 +83,7 @@ export interface CompletionAssistantCandidate {
comment?: string | null;
data_type?: string | null;
signature?: string | null;
routine_id?: string | null;
}
export interface CompletionAssistantResponse {
@@ -1136,6 +1137,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;
@@ -1785,6 +1788,9 @@ export interface TreeNode {
parentId: string;
offset: number;
pageSize: number;
/** Backend filter and global-search identity for OceanBase metadata pages. */
searchFilter?: string;
searchQuery?: string;
/**
* Identity of the row that was expected to open this page: the peek row the
* previous page fetched but did not display. Offset paging is not snapshot
@@ -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,
+160 -9
View File
@@ -998,6 +998,13 @@ pub async fn list_tables_core(
object_types: Option<&[String]>,
table_name_filter: Option<&TableNameFilter>,
) -> Result<Vec<db::TableInfo>, String> {
let db_config = connection_config(state, connection_id).await;
if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OceanbaseOracle) {
validate_oceanbase_metadata_page(limit, offset)?;
if limit == Some(0) {
return Ok(vec![]);
}
}
let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "tables").await;
let result = retry_metadata_connection_for_session(
state,
@@ -2416,6 +2423,7 @@ async fn list_tables_once(
if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) {
let use_mongodb_collection_listing = uses_mongodb_agent_collection_listing(db_config.as_ref());
let is_oracle = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle);
let is_oceanbase = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OceanbaseOracle);
let is_tdengine = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Tdengine);
let use_agent_table_paging = db_config.as_ref().is_some_and(supports_agent_table_paging);
let filter_locally_after_oracle_comments =
@@ -2501,7 +2509,7 @@ async fn list_tables_once(
}
let final_offset = if filter_locally_after_comments || force_local_table_name_filter {
offset
} else if agent_paging_likely_applied(use_agent_table_paging, limit, tables.len()) {
} else if is_oceanbase || agent_paging_likely_applied(use_agent_table_paging, limit, tables.len()) {
Some(0)
} else {
offset
@@ -5496,6 +5504,39 @@ for line in sys.stdin:
assert_eq!(columns[1].character_maximum_length, Some(64));
}
#[test]
fn routine_completion_wire_distinguishes_legacy_agents_from_empty_searches() {
let legacy: super::AgentCompletionAssistantResponse = serde_json::from_value(serde_json::json!({
"candidates": [], "incomplete": false, "fallback_used": false
}))
.unwrap();
assert!(!legacy.routine_search_supported);
let current: super::AgentCompletionAssistantResponse = serde_json::from_value(serde_json::json!({
"candidates": [], "incomplete": false, "fallback_used": false, "routine_search_supported": true
}))
.unwrap();
assert!(current.routine_search_supported);
assert!(current.response.candidates.is_empty());
assert!(!current.response.incomplete);
}
#[test]
fn package_completion_wire_preserves_overload_identity_and_unknown_signatures() {
let wire = serde_json::json!({
"candidates": [
{"name":"RUN", "kind":"procedure", "schema":"APP", "parent_schema":"APP", "parent_name":"PKG", "signature":"", "routine_id":"3:APP:101:1"},
{"name":"RUN", "kind":"function", "schema":"APP", "parent_schema":"APP", "parent_name":"PKG", "signature":null, "routine_id":"3:APP:101:2", "data_type":"NUMBER"}
], "incomplete":false, "fallback_used":false, "routine_search_supported":true
});
let response: super::AgentCompletionAssistantResponse = serde_json::from_value(wire).unwrap();
let result = serde_json::to_value(response.response).unwrap();
assert_eq!(result["candidates"][0]["signature"], "");
assert!(result["candidates"][1]["signature"].is_null());
assert_eq!(result["candidates"][0]["routine_id"], "3:APP:101:1");
assert_eq!(result["candidates"][1]["routine_id"], "3:APP:101:2");
assert_eq!(result["candidates"][1]["data_type"], "NUMBER");
}
#[test]
fn detects_unsupported_agent_completion_assistant_errors() {
assert!(super::is_agent_completion_assistant_unsupported(
@@ -6571,6 +6612,12 @@ pub async fn list_objects_core(
table_name_filter: Option<&TableNameFilter>,
) -> Result<Vec<db::ObjectInfo>, String> {
let db_config = connection_config(state, connection_id).await;
if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OceanbaseOracle) {
validate_oceanbase_metadata_page(limit, offset)?;
if limit == Some(0) {
return Ok(vec![]);
}
}
let filter_locally_after_oracle_comments = db_config.as_ref().is_some_and(|config| {
config.db_type == DatabaseType::Oracle && filter.is_some_and(|filter| !filter.trim().is_empty())
});
@@ -6682,6 +6729,14 @@ async fn close_ephemeral_agent_metadata_session(
}
}
#[derive(serde::Deserialize)]
struct AgentCompletionAssistantResponse {
#[serde(flatten)]
response: db::CompletionAssistantResponse,
#[serde(default)]
routine_search_supported: bool,
}
pub async fn completion_assistant_search_core(
state: &AppState,
request: db::CompletionAssistantRequest,
@@ -6769,13 +6824,25 @@ pub async fn completion_assistant_search_core(
let db_config = connection_config(state, &request.connection_id).await;
let mut client = client.lock().await;
match client
.completion_assistant_search::<db::CompletionAssistantResponse>(
.completion_assistant_search::<AgentCompletionAssistantResponse>(
&request,
agent_metadata_timeout(db_config.as_ref()),
)
.await
{
Ok(mut response) => {
Ok(agent_response) => {
if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OceanbaseOracle)
&& request.object_kinds.iter().any(db::CompletionAssistantObjectKind::is_routine_like)
&& !agent_response.routine_search_supported
{
// Older JDBC agents return a successful empty list for routine requests.
// Let the frontend retain its existing schema-list fallback only there.
return Err(
"OceanBase agent does not support filtered routine completion; update the agent"
.to_string(),
);
}
let mut response = agent_response.response;
response.fallback_used = false;
return Ok(response);
}
@@ -6849,6 +6916,7 @@ async fn completion_assistant_fallback_core(
parent_name: None,
comment: None,
data_type: None,
routine_id: None,
signature: None,
});
}
@@ -6887,6 +6955,7 @@ async fn completion_assistant_fallback_core(
parent_name: table.parent_name,
comment: table.comment,
data_type: None,
routine_id: None,
signature: None,
});
if candidates.len() >= limit {
@@ -6939,6 +7008,7 @@ async fn completion_assistant_fallback_core(
parent_name: Some(table.to_string()),
comment: column.comment,
data_type: Some(column.data_type),
routine_id: None,
signature: None,
});
}
@@ -7019,6 +7089,7 @@ async fn oracle_external_driver_completion_synonyms(
parent_name: None,
comment: None,
data_type: Some("SYNONYM".to_string()),
routine_id: None,
signature: None,
})
})
@@ -7248,7 +7319,9 @@ async fn list_objects_once(
}
if let Some(agent_client) = extract_pool!(pool_handle.as_ref(), Agent) {
let is_oracle = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle);
let is_oceanbase = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OceanbaseOracle);
let use_oracle_agent_paging = db_config.as_ref().is_some_and(is_default_oracle_agent_config);
let use_agent_paging = use_oracle_agent_paging || is_oceanbase;
let filter_locally_after_oracle_comments =
is_oracle && filter.is_some_and(|filter| !filter.trim().is_empty());
let timeout_duration = agent_metadata_timeout(db_config.as_ref());
@@ -7272,14 +7345,14 @@ async fn list_objects_once(
let agent_filter = if filter_locally_after_oracle_comments { None } else { filter };
let agent_limit = if filter_locally_after_oracle_comments || force_local_table_name_filter {
None
} else if use_oracle_agent_paging {
} else if use_agent_paging {
limit
} else {
None
};
let agent_offset = if filter_locally_after_oracle_comments || force_local_table_name_filter {
None
} else if use_oracle_agent_paging {
} else if use_agent_paging {
offset
} else {
None
@@ -7296,6 +7369,9 @@ async fn list_objects_once(
)
.await
{
Ok(objects) if is_oceanbase => {
return Ok(ObjectListOutcome { objects, paging_applied: !force_local_table_name_filter });
}
Ok(mut objects) if !objects.is_empty() => {
if is_oracle {
load_oracle_table_comments_for_objects(
@@ -7858,6 +7934,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;
@@ -7866,7 +7956,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,
@@ -7874,13 +7964,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(
@@ -7891,6 +8004,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,
@@ -7902,6 +8038,7 @@ async fn get_columns_core_for_session_inner(
use_client_session_context,
None,
true,
current_schema,
)
.await
}
@@ -7924,6 +8061,7 @@ async fn get_columns_core_for_existing_pool(
true,
Some(pool_key),
false,
None,
)
.await
}
@@ -7938,6 +8076,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);
@@ -8106,10 +8245,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
@@ -9838,6 +9980,15 @@ fn object_types_only_custom_types(object_types: Option<&[String]>) -> bool {
})
}
fn validate_oceanbase_metadata_page(limit: Option<usize>, offset: Option<usize>) -> Result<(), String> {
for (name, value) in [("limit", limit), ("offset", offset)] {
if value.is_some_and(|value| value > i32::MAX as usize) {
return Err(format!("OceanBase metadata {name} must not exceed {}", i32::MAX));
}
}
Ok(())
}
fn is_default_oracle_agent_config(config: &ConnectionConfig) -> bool {
// Only the default go-oracle agent handles filtered/paged metadata; legacy profiles keep Rust fallback paging.
matches!(config.db_type, DatabaseType::Oracle)
@@ -9854,7 +10005,7 @@ fn uses_oracle_metadata_object_source(config: Option<&ConnectionConfig>, object_
fn supports_agent_table_paging(config: &ConnectionConfig) -> bool {
// Keep paging opt-in until each legacy agent is known to apply metadata constraints server-side.
matches!(config.db_type, DatabaseType::Tdengine)
matches!(config.db_type, DatabaseType::Tdengine | DatabaseType::OceanbaseOracle)
|| crate::agent_catalog::agent_key(&config.db_type, config.driver_profile.as_deref()) == Some("cache")
|| is_default_oracle_agent_config(config)
}
@@ -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,
@@ -0,0 +1,301 @@
use dbx_core::connection::AppState;
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::schema::{list_objects_core, list_tables_core, TableNameFilter};
use serde_json::{json, Value};
use std::time::Duration;
struct Fixture {
state: AppState,
directory: tempfile::TempDir,
_listener: tokio::net::TcpListener,
}
impl Fixture {
async fn new() -> Self {
Self::for_database_type(DatabaseType::OceanbaseOracle).await
}
async fn for_database_type(db_type: DatabaseType) -> Self {
let directory = tempfile::tempdir().unwrap();
let storage = dbx_core::persistence::test_storage::open(&directory.path().join("state.db")).await.unwrap();
let state = AppState::new_with_plugin_and_agent_dir_and_app_version(
storage,
directory.path().join("plugins"),
directory.path().join("agents"),
"test",
);
let key = dbx_core::database_capabilities::agent_key(&db_type, None).unwrap();
let launch = state.agent_manager.driver_launch_config_path(key);
std::fs::create_dir_all(launch.parent().unwrap()).unwrap();
let script = directory.path().join("agent.py");
std::fs::write(
&script,
r#"import json, pathlib, sys
root = pathlib.Path(sys.argv[1])
rows = [
{'name':'A', 'object_type':'TABLE', 'schema':'APP', 'comment':'quarterly report'},
{'name':'B', 'object_type':'TABLE', 'schema':'APP', 'comment':'quarterly report'},
{'name':'B', 'object_type':'VIEW', 'schema':'APP', 'comment':'quarterly report'},
{'name':'C', 'object_type':'TABLE', 'schema':'APP', 'comment':'unrelated'},
]
print(json.dumps({'ready':True}), flush=True)
for line in sys.stdin:
request = json.loads(line)
with (root/'requests.jsonl').open('a') as log:
log.write(json.dumps(request)+'\n')
method = request['method']; params = request.get('params', {})
if method == 'handshake':
result = {'protocolVersion':2, 'agentProtocolVersion':2, 'capabilities':['multi_session']}
elif method in ['list_objects', 'list_tables']:
result = rows
if params.get('filter'):
term = params['filter'].strip().lower()
result = [r for r in result if term in r['name'].lower() or term in r['comment'].lower()]
if 'object_types' in params:
result = [r for r in result if r['object_type'] in params['object_types']]
result = result[params.get('offset', 0):]
if params.get('limit', 0) > 0:
result = result[:params['limit']]
if method == 'list_tables':
result = [{'name':r['name'], 'table_type':r['object_type'], 'comment':r['comment']} for r in result]
else:
result = {'ok':True}
print(json.dumps({'jsonrpc':'2.0', 'id':request['id'], 'result':result}), flush=True)
"#,
)
.unwrap();
std::fs::write(
launch,
serde_json::to_vec(&json!({
"command": if cfg!(windows) { "python" } else { "python3" },
"args": [script, directory.path()]
}))
.unwrap(),
)
.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let config: ConnectionConfig = serde_json::from_value(json!({
"id":"ob", "name":"OceanBase paging fixture", "db_type":db_type,
"host":"127.0.0.1", "port":listener.local_addr().unwrap().port(),
"username":"fixture", "password":"", "database":"APP", "save_password":true,
"keepalive_interval_secs":0, "idle_timeout_secs":0
}))
.unwrap();
state.configs.write().await.insert("ob".into(), config);
Self { state, directory, _listener: listener }
}
fn requests(&self, method: &str) -> Vec<Value> {
std::fs::read_to_string(self.directory.path().join("requests.jsonl"))
.unwrap_or_default()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.filter(|request| request["method"] == method)
.collect()
}
async fn shutdown(self) {
self.state.shutdown(Duration::from_secs(2)).await;
}
}
#[tokio::test]
async fn oceanbase_objects_request_bounded_comment_pages() {
let fixture = Fixture::new().await;
let types = vec!["TABLE".into(), "VIEW".into()];
let page =
list_objects_core(&fixture.state, "ob", "APP", "APP", Some("report"), Some(2), Some(2), Some(&types), None)
.await
.unwrap();
assert_eq!(page.len(), 1);
assert_eq!(page[0].name, "B");
assert_eq!(page[0].object_type, "VIEW");
let requests = fixture.requests("list_objects");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0]["params"]["limit"], 2);
assert_eq!(requests[0]["params"]["offset"], 2);
assert_eq!(requests[0]["params"]["filter"], "report");
assert_eq!(requests[0]["params"]["object_types"], json!(["TABLE", "VIEW"]));
fixture.shutdown().await;
}
#[tokio::test]
async fn oceanbase_tables_request_bounded_comment_pages() {
let fixture = Fixture::new().await;
let types = vec!["TABLE".into(), "VIEW".into()];
let page =
list_tables_core(&fixture.state, "ob", "APP", "APP", Some("report"), Some(2), Some(2), Some(&types), None)
.await
.unwrap();
assert_eq!(page.len(), 1);
assert_eq!(page[0].name, "B");
assert_eq!(page[0].table_type, "VIEW");
let requests = fixture.requests("list_tables");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0]["params"]["limit"], 2);
assert_eq!(requests[0]["params"]["offset"], 2);
assert_eq!(requests[0]["params"]["filter"], "report");
assert_eq!(requests[0]["params"]["object_types"], json!(["TABLE", "VIEW"]));
fixture.shutdown().await;
}
#[tokio::test]
async fn oceanbase_empty_and_out_of_range_pages_do_not_query_agent() {
let fixture = Fixture::new().await;
let too_large = i32::MAX as usize + 1;
let mut errors = Vec::new();
for (limit, offset) in [(Some(0), Some(0)), (Some(too_large), None), (Some(1), Some(too_large))] {
let objects = list_objects_core(&fixture.state, "ob", "APP", "APP", None, limit, offset, None, None).await;
let tables = list_tables_core(&fixture.state, "ob", "APP", "APP", None, limit, offset, None, None).await;
if limit == Some(0) {
assert!(objects.as_ref().unwrap().is_empty());
assert!(tables.as_ref().unwrap().is_empty());
}
errors.push((objects.is_err(), tables.is_err()));
}
assert_eq!(
(
errors,
fixture.requests("list_objects").len(),
fixture.requests("list_tables").len(),
fixture.requests("open_session").len()
),
(vec![(false, false), (true, true), (true, true)], 0, 0, 0)
);
fixture.shutdown().await;
}
#[tokio::test]
async fn oceanbase_offset_without_limit_is_applied_once() {
let fixture = Fixture::new().await;
let objects =
list_objects_core(&fixture.state, "ob", "APP", "APP", Some("report"), None, Some(1), None, None).await.unwrap();
let tables =
list_tables_core(&fixture.state, "ob", "APP", "APP", Some("report"), None, Some(1), None, None).await.unwrap();
assert_eq!(
objects.iter().map(|row| (row.name.as_str(), row.object_type.as_str())).collect::<Vec<_>>(),
vec![("B", "TABLE"), ("B", "VIEW")]
);
assert_eq!(
tables.iter().map(|row| (row.name.as_str(), row.table_type.as_str())).collect::<Vec<_>>(),
vec![("B", "TABLE"), ("B", "VIEW")]
);
for method in ["list_objects", "list_tables"] {
let requests = fixture.requests(method);
assert_eq!(requests.len(), 1);
assert!(requests[0]["params"]["limit"].is_null());
assert_eq!(requests[0]["params"]["offset"], 1);
}
fixture.shutdown().await;
}
#[tokio::test]
async fn oceanbase_empty_pages_and_maximum_protocol_integers_remain_authoritative() {
let fixture = Fixture::new().await;
let max = i32::MAX as usize;
for (limit, offset) in [(2, 3), (max, max)] {
assert!(list_objects_core(
&fixture.state,
"ob",
"APP",
"APP",
Some("report"),
Some(limit),
Some(offset),
None,
None
)
.await
.unwrap()
.is_empty());
assert!(list_tables_core(
&fixture.state,
"ob",
"APP",
"APP",
Some("report"),
Some(limit),
Some(offset),
None,
None
)
.await
.unwrap()
.is_empty());
}
for method in ["list_objects", "list_tables"] {
let requests = fixture.requests(method);
assert_eq!(requests.len(), 2);
assert_eq!(requests[0]["params"]["limit"], 2);
assert_eq!(requests[0]["params"]["offset"], 3);
assert_eq!(requests[1]["params"]["limit"], i32::MAX);
assert_eq!(requests[1]["params"]["offset"], i32::MAX);
}
fixture.shutdown().await;
}
#[tokio::test]
async fn oceanbase_include_exclude_filters_stay_before_local_pagination() {
let fixture = Fixture::new().await;
let name_filter = TableNameFilter { include_patterns: vec!["B%".into()], exclude_patterns: vec!["A%".into()] };
let objects = list_objects_core(
&fixture.state,
"ob",
"APP",
"APP",
Some("report"),
Some(1),
Some(1),
None,
Some(&name_filter),
)
.await
.unwrap();
let tables = list_tables_core(
&fixture.state,
"ob",
"APP",
"APP",
Some("report"),
Some(1),
Some(1),
None,
Some(&name_filter),
)
.await
.unwrap();
assert_eq!(objects.len(), 1);
assert_eq!((&*objects[0].name, &*objects[0].object_type), ("B", "VIEW"));
assert_eq!(tables.len(), 1);
assert_eq!((&*tables[0].name, &*tables[0].table_type), ("B", "VIEW"));
for method in ["list_objects", "list_tables"] {
let requests = fixture.requests(method);
assert_eq!(requests.len(), 1);
assert!(requests[0]["params"]["limit"].is_null());
assert!(requests[0]["params"]["offset"].is_null());
assert_eq!(requests[0]["params"]["filter"], "report");
}
fixture.shutdown().await;
}
#[tokio::test]
async fn other_agents_keep_local_metadata_pagination() {
let fixture = Fixture::for_database_type(DatabaseType::Dameng).await;
let objects = list_objects_core(&fixture.state, "ob", "APP", "APP", Some("report"), Some(1), Some(2), None, None)
.await
.unwrap();
let tables = list_tables_core(&fixture.state, "ob", "APP", "APP", Some("report"), Some(1), Some(2), None, None)
.await
.unwrap();
assert_eq!(objects.len(), 1);
assert_eq!((&*objects[0].name, &*objects[0].object_type), ("B", "VIEW"));
assert_eq!(tables.len(), 1);
assert_eq!((&*tables[0].name, &*tables[0].table_type), ("B", "VIEW"));
for method in ["list_objects", "list_tables"] {
let requests = fixture.requests(method);
assert_eq!(requests.len(), 1);
assert!(requests[0]["params"]["limit"].is_null());
assert!(requests[0]["params"]["offset"].is_null());
}
fixture.shutdown().await;
}
@@ -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,
+56 -6
View File
@@ -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>(
@@ -4633,6 +4643,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', 'get_explain_info'):
sql = params.get('sql', '')
with state_lock:
@@ -4680,6 +4693,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) {
+10
View File
@@ -3440,6 +3440,7 @@ pub async fn completion_assistant_search(
parent_name: None,
comment: None,
data_type: None,
routine_id: None,
signature: None,
});
}
@@ -3467,6 +3468,7 @@ pub async fn completion_assistant_search(
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
data_type: None,
routine_id: None,
signature: None,
});
}
@@ -3495,6 +3497,7 @@ pub async fn completion_assistant_search(
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
data_type: get_opt_str(&row, "data_type"),
routine_id: None,
signature: None,
});
}
@@ -3518,6 +3521,7 @@ pub async fn completion_assistant_search(
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
data_type: Some(get_str_by_name(&row, "data_type")),
routine_id: None,
signature: None,
});
}
@@ -4989,6 +4993,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"),
@@ -5062,6 +5068,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"),
@@ -8847,6 +8855,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"),
@@ -4051,6 +4051,7 @@ async fn completion_assistant_search_inner(
parent_name: None,
comment: None,
data_type: None,
routine_id: None,
signature: None,
});
}
@@ -4080,6 +4081,7 @@ async fn completion_assistant_search_inner(
parent_name: row.try_get::<_, Option<String>>(5).ok().flatten(),
comment: row.try_get::<_, Option<String>>(3).ok().flatten(),
data_type: None,
routine_id: None,
signature: None,
});
}
@@ -4111,6 +4113,7 @@ async fn completion_assistant_search_inner(
parent_name: None,
comment: None,
data_type: Some("PACKAGE".to_string()),
routine_id: None,
signature: None,
});
}
@@ -4161,6 +4164,7 @@ async fn completion_assistant_search_inner(
parent_name: None,
comment: row.try_get::<_, Option<String>>(3).ok().flatten(),
data_type: row.try_get::<_, Option<String>>(4).ok().flatten(),
routine_id: None,
signature: row.try_get::<_, Option<String>>(5).ok().flatten(),
});
}
@@ -4184,6 +4188,7 @@ async fn completion_assistant_search_inner(
parent_name: None,
comment: row.try_get::<_, Option<String>>(2).ok().flatten(),
data_type: None,
routine_id: None,
signature: None,
});
}
@@ -4222,6 +4227,7 @@ async fn completion_assistant_search_inner(
parent_name: Some(table.to_string()),
comment: row.try_get::<_, Option<String>>(2).ok().flatten(),
data_type: Some(pg_row_try_string(&row, 1)),
routine_id: None,
signature: None,
});
}
@@ -7186,6 +7192,7 @@ pub async fn opengauss_package_members(
parent_name: Some(catalog_name.clone()),
comment: None,
data_type: row.try_get::<_, Option<String>>(3).ok().flatten(),
routine_id: None,
signature: row.try_get::<_, Option<String>>(2).ok().flatten(),
}
})
@@ -2475,6 +2475,7 @@ pub async fn completion_assistant_search(
parent_name: row.get::<&str, _>(4).map(str::to_string),
comment: row.get::<&str, _>(5).filter(|s: &&str| !s.is_empty()).map(|s| (*s).to_string()),
data_type: row.get::<&str, _>(6).map(str::to_string),
routine_id: None,
signature: None,
}
})
@@ -5669,6 +5670,7 @@ mod tests {
parent_name: None,
comment: None,
data_type: None,
routine_id: None,
signature: None,
};
@@ -5698,6 +5700,7 @@ mod tests {
parent_name: None,
comment: None,
data_type: None,
routine_id: None,
signature: None,
};
@@ -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,
+3
View File
@@ -1718,6 +1718,7 @@ fn sqlite_completion_schemas(
parent_name: None,
comment: None,
data_type: None,
routine_id: None,
signature: None,
})
.collect())
@@ -1772,6 +1773,7 @@ fn sqlite_completion_tables(
parent_name: None,
comment: None,
data_type: None,
routine_id: None,
signature: None,
})
})
@@ -1811,6 +1813,7 @@ fn sqlite_completion_columns(
parent_name: Some(table.to_string()),
comment: None,
data_type: Some(data_type),
routine_id: None,
signature: None,
});
if candidates.len() >= limit {
+2
View File
@@ -2870,6 +2870,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,
+8
View File
@@ -6951,6 +6951,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,
@@ -10131,6 +10133,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,
@@ -13349,6 +13353,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()),
@@ -13367,6 +13373,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()),
+3 -1
View File
@@ -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
}
+6
View File
@@ -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,
@@ -423,6 +427,8 @@ pub struct CompletionAssistantCandidate {
pub comment: Option<String>,
pub data_type: Option<String>,
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub routine_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -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());
}
+21 -7
View File
@@ -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]