mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(oceanbase): preserve object paging and completion metadata identity
This commit is contained in:
@@ -15,6 +15,9 @@ public final class ColumnInfo {
|
||||
private Integer character_maximum_length;
|
||||
private String character_set;
|
||||
private String collation;
|
||||
private String resolved_schema;
|
||||
private String resolved_table;
|
||||
private String resolved_object_type;
|
||||
|
||||
public ColumnInfo() {
|
||||
this("", "", true, null, false);
|
||||
@@ -128,6 +131,18 @@ public final class ColumnInfo {
|
||||
return collation;
|
||||
}
|
||||
|
||||
public String getResolved_schema() {
|
||||
return resolved_schema;
|
||||
}
|
||||
|
||||
public String getResolved_table() {
|
||||
return resolved_table;
|
||||
}
|
||||
|
||||
public String getResolved_object_type() {
|
||||
return resolved_object_type;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
@@ -176,6 +191,18 @@ public final class ColumnInfo {
|
||||
this.collation = collation;
|
||||
}
|
||||
|
||||
public void setResolved_schema(String resolved_schema) {
|
||||
this.resolved_schema = resolved_schema;
|
||||
}
|
||||
|
||||
public void setResolved_table(String resolved_table) {
|
||||
this.resolved_table = resolved_table;
|
||||
}
|
||||
|
||||
public void setResolved_object_type(String resolved_object_type) {
|
||||
this.resolved_object_type = resolved_object_type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -192,7 +219,10 @@ public final class ColumnInfo {
|
||||
&& Objects.equals(numeric_scale, that.numeric_scale)
|
||||
&& Objects.equals(character_maximum_length, that.character_maximum_length)
|
||||
&& Objects.equals(character_set, that.character_set)
|
||||
&& Objects.equals(collation, that.collation);
|
||||
&& Objects.equals(collation, that.collation)
|
||||
&& Objects.equals(resolved_schema, that.resolved_schema)
|
||||
&& Objects.equals(resolved_table, that.resolved_table)
|
||||
&& Objects.equals(resolved_object_type, that.resolved_object_type);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -209,7 +239,10 @@ public final class ColumnInfo {
|
||||
numeric_scale,
|
||||
character_maximum_length,
|
||||
character_set,
|
||||
collation
|
||||
collation,
|
||||
resolved_schema,
|
||||
resolved_table,
|
||||
resolved_object_type
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,6 +260,9 @@ public final class ColumnInfo {
|
||||
+ ", character_maximum_length=" + character_maximum_length
|
||||
+ ", character_set=" + character_set
|
||||
+ ", collation=" + collation
|
||||
+ ", resolved_schema=" + resolved_schema
|
||||
+ ", resolved_table=" + resolved_table
|
||||
+ ", resolved_object_type=" + resolved_object_type
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+334
-10
@@ -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);
|
||||
|
||||
+215
@@ -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<>();
|
||||
|
||||
+39
-7
@@ -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;
|
||||
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OceanBaseOracleSynonymColumnsTest {
|
||||
@Test
|
||||
void privateSynonymReturnsTargetColumnMetadataAcrossSchemas() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
List<ColumnInfo> columns = catalog.agent.getColumns("APP", "ORDER_ALIAS");
|
||||
|
||||
assertEquals(1, columns.size());
|
||||
ColumnInfo column = columns.getFirst();
|
||||
assertEquals("AMOUNT", column.getName());
|
||||
assertEquals("NUMBER(19,4)", column.getData_type());
|
||||
assertEquals(19, column.getNumeric_precision());
|
||||
assertEquals(4, column.getNumeric_scale());
|
||||
assertFalse(column.getIs_nullable());
|
||||
assertTrue(column.getIs_primary_key());
|
||||
assertEquals("0", column.getColumn_default());
|
||||
assertEquals("Order amount", column.getComment());
|
||||
}
|
||||
|
||||
private static Map<String, Object> amountColumn() {
|
||||
return Map.of("COLUMN_NAME", "AMOUNT", "DATA_TYPE", "NUMBER", "NULLABLE", "N",
|
||||
"DATA_PRECISION", 19, "DATA_SCALE", 4, "DATA_LENGTH", 22,
|
||||
"DATA_DEFAULT", "0", "COMMENTS", "Order amount", "IS_PK", 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void synonymChainRetainsQuotedTargetNamesAndVerifiedViewIdentity() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "Bridge.Owner", "Next.Alias", null);
|
||||
catalog.synonym("Bridge.Owner", "Next.Alias", "Sales.Owner", "Order\"View", null);
|
||||
catalog.object("Sales.Owner", "Order\"View", "VIEW");
|
||||
catalog.columns("Sales.Owner", "Order\"View", amountColumn());
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("APP", "ORDER_ALIAS").getFirst();
|
||||
|
||||
assertEquals("Sales.Owner", column.getResolved_schema());
|
||||
assertEquals("Order\"View", column.getResolved_table());
|
||||
assertEquals("VIEW", column.getResolved_object_type());
|
||||
assertEquals("NUMBER(19,4)", column.getData_type());
|
||||
assertFalse(column.getIs_nullable());
|
||||
assertTrue(column.getIs_primary_key());
|
||||
}
|
||||
|
||||
private record Name(String owner, String name) {}
|
||||
|
||||
@Test
|
||||
void publicSynonymResolvesOnlyWhenSchemaIsNotExplicit() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("PUBLIC", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("APP", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("", "PUBLIC_ORDERS").getFirst();
|
||||
assertEquals("SALES", column.getResolved_schema());
|
||||
assertEquals("ORDERS", column.getResolved_table());
|
||||
assertEquals("TABLE", column.getResolved_object_type());
|
||||
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.startsWith("ALTER SESSION")));
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumns("APP", "PUBLIC_ORDERS"));
|
||||
assertTrue(error.getMessage().contains("not found or not accessible"), error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicSynonymSupportsTheOceanBaseDictionaryPublicOwner() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("APP", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("", "PUBLIC_ORDERS").getFirst();
|
||||
|
||||
assertEquals("SALES", column.getResolved_schema());
|
||||
assertEquals("ORDERS", column.getResolved_table());
|
||||
assertEquals("TABLE", column.getResolved_object_type());
|
||||
assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "PUBLIC_ORDERS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedSchemaContextResolvesLocalNamesAndStillAllowsPublicFallback() {
|
||||
var catalog = new Catalog();
|
||||
catalog.columns("Reporting", "LOCAL_ORDERS", amountColumn());
|
||||
catalog.synonym("PUBLIC", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
|
||||
assertEquals("AMOUNT", catalog.agent.getColumnsInContext("", "LOCAL_ORDERS", "Reporting").getFirst().getName());
|
||||
assertEquals("SALES", catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting").getFirst().getResolved_schema());
|
||||
assertEquals("APP", catalog.currentSchema);
|
||||
assertThrows(RuntimeException.class, () -> catalog.agent.getColumnsInContext("Reporting", "PUBLIC_ORDERS", "APP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void localNamespaceObjectsShadowPublicSynonymsEvenWhenTheyHaveNoColumns() {
|
||||
for (String type : List.of("TABLE", "VIEW", "SEQUENCE", "PROCEDURE", "FUNCTION", "PACKAGE", "TYPE")) {
|
||||
var catalog = new Catalog();
|
||||
catalog.object("APP", "ORDERS", type);
|
||||
catalog.synonym("PUBLIC", "ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("", "ORDERS"), type);
|
||||
assertFalse(catalog.columnReads.contains(new Name("SALES", "ORDERS")), type);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void remoteSynonymIsRejectedBeforeAnyTargetQuery() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "REMOTE_ORDERS", "SALES", "ORDERS", "REMOTE.EXAMPLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumns("APP", "REMOTE_ORDERS"));
|
||||
|
||||
assertTrue(error.getMessage().contains("DBLink"), error.getMessage());
|
||||
assertTrue(error.getMessage().contains("not supported"), error.getMessage());
|
||||
assertEquals(List.of(new Name("APP", "REMOTE_ORDERS")), catalog.columnReads);
|
||||
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.contains("@")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cyclicSynonymsFailWithAnExplicitCycleError() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "A", "OTHER", "B", null);
|
||||
catalog.synonym("OTHER", "B", "APP", "A", null);
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "A"));
|
||||
|
||||
assertTrue(error.getMessage().contains("cycle"), error.getMessage());
|
||||
assertEquals(List.of(new Name("APP", "A"), new Name("OTHER", "B")), catalog.columnReads);
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteSynonymTargetMetadataNeverFallsBackToTheSessionOwner() {
|
||||
for (String[] target : new String[][]{{null, "ORDERS"}, {"SALES", null}, {"", "ORDERS"}, {"SALES", ""}}) {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "BROKEN", target[0], target[1], null);
|
||||
catalog.columns("APP", "ORDERS", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "BROKEN"));
|
||||
|
||||
assertTrue(error.getMessage() != null && error.getMessage().contains("Incomplete synonym target metadata"), String.valueOf(error));
|
||||
assertEquals(List.of(new Name("APP", "BROKEN")), catalog.columnReads);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void materializedViewIdentityTakesPrecedenceOverItsTableEntry() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "MV_ALIAS", "SALES", "ORDER_TOTALS", null);
|
||||
catalog.objects.put(new Name("SALES", "ORDER_TOTALS"), List.of(
|
||||
Map.of("OBJECT_TYPE", "TABLE"), Map.of("OBJECT_TYPE", "MATERIALIZED VIEW")));
|
||||
catalog.columns("SALES", "ORDER_TOTALS", amountColumn());
|
||||
|
||||
assertEquals("MATERIALIZED VIEW", catalog.agent.getColumns("APP", "MV_ALIAS").getFirst().getResolved_object_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownTargetTypeIsNotPromotedToTable() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "SALES", "ORDERS", null);
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("APP", "ORDER_ALIAS").getFirst();
|
||||
|
||||
assertEquals("SALES", column.getResolved_schema());
|
||||
assertEquals("ORDERS", column.getResolved_table());
|
||||
assertNull(column.getResolved_object_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void localTableAndPrivateSynonymTakePrecedenceOverPublicSynonym() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("PUBLIC", "ORDERS", "OTHER", "ORDERS", null);
|
||||
catalog.synonym("PUBLIC", "ORDER_ALIAS", "OTHER", "ORDERS", null);
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "APP", "ORDERS", null);
|
||||
catalog.object("APP", "ORDERS", "TABLE");
|
||||
catalog.columns("APP", "ORDERS", amountColumn());
|
||||
|
||||
assertEquals("AMOUNT", catalog.agent.getColumns("", "ORDERS").getFirst().getName());
|
||||
assertEquals("APP", catalog.agent.getColumns("", "ORDER_ALIAS").getFirst().getResolved_schema());
|
||||
assertFalse(catalog.columnReads.contains(new Name("OTHER", "ORDERS")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void brokenPrivateTargetDoesNotFallThroughToPublicSynonyms() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDERS", "SALES", "MISSING", null);
|
||||
catalog.synonym("PUBLIC", "ORDERS", "OTHER", "ORDERS", null);
|
||||
catalog.synonym("PUBLIC", "MISSING", "OTHER", "ORDERS", null);
|
||||
catalog.columns("OTHER", "ORDERS", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("", "ORDERS"));
|
||||
|
||||
assertTrue(error.getMessage().contains("SALES.MISSING not found or not accessible"), error.getMessage());
|
||||
assertFalse(catalog.columnReads.contains(new Name("OTHER", "ORDERS")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAndInaccessibleTargetsAreNotSuccessfulEmptyColumns() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "INACCESSIBLE", "SALES", "HIDDEN", null);
|
||||
|
||||
for (String name : List.of("MISSING", "INACCESSIBLE")) {
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", name));
|
||||
assertTrue(error.getMessage().contains("not found or not accessible"), error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dictionaryFailuresPreserveTheOriginalSqlException() {
|
||||
for (String view : List.of("ALL_TAB_COLUMNS", "ALL_OBJECTS", "ALL_SYNONYMS")) {
|
||||
var catalog = new Catalog();
|
||||
catalog.failure = new SQLException("insufficient privileges", "42000", 1031);
|
||||
catalog.failureView = view;
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "ORDERS"));
|
||||
|
||||
assertSame(catalog.failure, error.getCause(), view);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void longAcyclicChainHasABoundedResolutionLimit() {
|
||||
var catalog = new Catalog();
|
||||
for (int index = 0; index < 40; index++) {
|
||||
catalog.synonym("APP", "S" + index, "APP", "S" + (index + 1), null);
|
||||
}
|
||||
catalog.columns("APP", "S40", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "S0"));
|
||||
|
||||
assertTrue(error.getMessage().contains("exceeded 32 links"), error.getMessage());
|
||||
assertTrue(catalog.columnReads.size() <= 33);
|
||||
assertFalse(catalog.columnReads.contains(new Name("APP", "S40")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invisibleForeignLocalObjectCannotBeReplacedByAPublicTarget() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "Reporting", "PUBLIC_ORDERS", null);
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertTrue(error.getMessage().contains("does not match"), error.getMessage());
|
||||
assertEquals("APP", catalog.currentSchema);
|
||||
}
|
||||
|
||||
private record NativeTarget(String owner, String name, String databaseLink) {}
|
||||
|
||||
@Test
|
||||
void nativePublicResolutionFailureRestoresTheExactOriginalSchema() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.currentSchema = "Original.Owner";
|
||||
catalog.nativeFailure = new SQLException("NAME_RESOLVE not available", "42000", 6550);
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertSame(catalog.nativeFailure, error.getCause());
|
||||
assertEquals("Original.Owner", catalog.currentSchema);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativePublicResolutionRejectsRemoteTargetsWithoutRemoteQueries() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", "REMOTE.EXAMPLE");
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertTrue(error.getMessage().contains("DBLink"), error.getMessage());
|
||||
assertEquals("APP", catalog.currentSchema);
|
||||
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.contains("@")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaRestoreFailureCannotReturnSuccessfulColumns() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.restoreFailure = new SQLException("restore failed");
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertSame(catalog.restoreFailure, error.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaRestoreFailureIsSuppressedOnTheOriginalResolutionError() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.nativeFailure = new SQLException("resolution failed");
|
||||
catalog.restoreFailure = new SQLException("restore failed");
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertSame(catalog.nativeFailure, error.getCause());
|
||||
assertArrayEquals(new Throwable[]{catalog.restoreFailure}, error.getCause().getSuppressed());
|
||||
}
|
||||
|
||||
private static Catalog publicCatalog() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private static final class Catalog {
|
||||
final OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
final Map<Name, List<Map<String, Object>>> columns = new HashMap<>();
|
||||
final Map<Name, List<Map<String, Object>>> synonyms = new HashMap<>();
|
||||
final Map<Name, List<Map<String, Object>>> objects = new HashMap<>();
|
||||
final Map<Name, NativeTarget> nativeTargets = new HashMap<>();
|
||||
final List<String> sql = new ArrayList<>();
|
||||
final List<Name> columnReads = new ArrayList<>();
|
||||
String currentSchema = "APP";
|
||||
SQLException failure;
|
||||
SQLException nativeFailure;
|
||||
SQLException restoreFailure;
|
||||
String failureView;
|
||||
|
||||
Catalog() {
|
||||
Connection connection = proxy(Connection.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("prepareStatement")) return statement((String) args[0]);
|
||||
if (method.getName().equals("prepareCall")) return nativeStatement((String) args[0]);
|
||||
if (method.getName().equals("createStatement")) {
|
||||
return proxy(Statement.class, (stmt, call, params) -> {
|
||||
if (call.getName().equals("executeQuery")) {
|
||||
sql.add((String) params[0]);
|
||||
return rows(List.of(Map.of("CURRENT_SCHEMA", currentSchema)));
|
||||
}
|
||||
if (call.getName().equals("execute")) {
|
||||
String query = (String) params[0];
|
||||
sql.add(query);
|
||||
String prefix = "ALTER SESSION SET CURRENT_SCHEMA = ";
|
||||
assertTrue(query.startsWith(prefix), query);
|
||||
String identifier = query.substring(prefix.length());
|
||||
if (restoreFailure != null && identifier.equals("\"APP\"")) throw restoreFailure;
|
||||
currentSchema = identifier.substring(1, identifier.length() - 1).replace("\"\"", "\"");
|
||||
return false;
|
||||
}
|
||||
return defaultValue(call.getReturnType());
|
||||
});
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
}
|
||||
|
||||
void synonym(String owner, String name, String targetOwner, String targetName, String link) {
|
||||
var row = new HashMap<String, Object>();
|
||||
row.put("TABLE_OWNER", targetOwner);
|
||||
row.put("TABLE_NAME", targetName);
|
||||
row.put("DB_LINK", link);
|
||||
synonyms.put(new Name(owner, name), List.of(row));
|
||||
object(owner, name, "SYNONYM");
|
||||
}
|
||||
|
||||
void object(String owner, String name, String type) {
|
||||
objects.put(new Name(owner, name), List.of(Map.of("OBJECT_TYPE", type)));
|
||||
}
|
||||
|
||||
void columns(String owner, String name, Map<String, Object> column) {
|
||||
columns.put(new Name(owner, name), List.of(column));
|
||||
}
|
||||
|
||||
void nativeResult(String owner, String name, String targetOwner, String targetName, String link) {
|
||||
nativeTargets.put(new Name(owner, name), new NativeTarget(targetOwner, targetName, link));
|
||||
}
|
||||
|
||||
private CallableStatement nativeStatement(String query) {
|
||||
sql.add(query);
|
||||
Map<Integer, Object> parameters = new HashMap<>();
|
||||
NativeTarget[] result = {null};
|
||||
return proxy(CallableStatement.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("setString") || method.getName().equals("setInt")) {
|
||||
parameters.put((Integer) args[0], args[1]);
|
||||
return null;
|
||||
}
|
||||
if (method.getName().equals("execute")) {
|
||||
assertEquals(0, parameters.get(2), "OceanBase table resolution requires context 0");
|
||||
if (nativeFailure != null) throw nativeFailure;
|
||||
String identifier = (String) parameters.get(1);
|
||||
assertTrue(identifier.startsWith("\"") && identifier.endsWith("\""), identifier);
|
||||
String name = identifier.substring(1, identifier.length() - 1).replace("\"\"", "\"");
|
||||
result[0] = nativeTargets.get(new Name(currentSchema, name));
|
||||
assertNotNull(result[0], "Unexpected native resolution for " + currentSchema + "." + name);
|
||||
return false;
|
||||
}
|
||||
if (method.getName().equals("getString")) {
|
||||
return switch ((Integer) args[0]) {
|
||||
case 3 -> result[0].owner();
|
||||
case 4 -> result[0].name();
|
||||
case 6 -> result[0].databaseLink();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private PreparedStatement statement(String query) {
|
||||
sql.add(query);
|
||||
Map<Integer, String> parameters = new HashMap<>();
|
||||
return proxy(PreparedStatement.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("setString")) {
|
||||
parameters.put((Integer) args[0], (String) args[1]);
|
||||
return null;
|
||||
}
|
||||
if (method.getName().equals("executeQuery")) {
|
||||
if (failure != null && query.contains(failureView)) throw failure;
|
||||
Name name = new Name(parameters.get(1), parameters.get(2));
|
||||
if (query.contains("FROM ALL_TAB_COLUMNS")) {
|
||||
assertEquals(name, new Name(parameters.get(3), parameters.get(4)), "PK and column owners must agree");
|
||||
columnReads.add(name);
|
||||
return rows(columns.getOrDefault(name, List.of()));
|
||||
}
|
||||
if (query.contains("FROM ALL_SYNONYMS")) return rows(synonyms.getOrDefault(name, List.of()));
|
||||
if (query.contains("FROM ALL_OBJECTS")) return rows(objects.getOrDefault(name, List.of()));
|
||||
throw new AssertionError("Unexpected metadata query: " + query);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static ResultSet rows(List<Map<String, Object>> data) {
|
||||
int[] index = {-1};
|
||||
boolean[] wasNull = {false};
|
||||
return proxy(ResultSet.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("next")) return ++index[0] < data.size();
|
||||
if (method.getName().equals("wasNull")) return wasNull[0];
|
||||
if (method.getName().equals("getString") || method.getName().equals("getInt") || method.getName().equals("getObject")) {
|
||||
Object value = args[0] instanceof Integer
|
||||
? data.get(index[0]).values().iterator().next() : data.get(index[0]).get(args[0]);
|
||||
wasNull[0] = value == null;
|
||||
if (method.getName().equals("getObject")) return value;
|
||||
if (method.getName().equals("getInt")) return value == null ? 0 : ((Number) value).intValue();
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == boolean.class) return false;
|
||||
if (type == int.class) return 0;
|
||||
if (type == long.class) return 0L;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Vendored
+108
@@ -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();
|
||||
});
|
||||
+104
-5
@@ -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());
|
||||
|
||||
|
||||
+68
@@ -7,6 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata";
|
||||
import type { QueryEditorProps } from "../queryEditorTypes";
|
||||
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
import { buildSelectStarExpansion, buildSqlCompletionItems, getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
|
||||
import { buildSqlSemanticDiagnostics } from "@/lib/sql/semantic/diagnostics";
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
|
||||
vi.mock("@/lib/backend/api", () => ({}));
|
||||
@@ -42,6 +44,72 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
}
|
||||
|
||||
describe("QueryEditor completion metadata ownership", () => {
|
||||
it.each([
|
||||
["Alias", "PUBLIC_ID"],
|
||||
["APP.Alias", "PRIVATE_ID"],
|
||||
['"Alias"', "QUOTED_ID"],
|
||||
])("uses only the cached OceanBase reference %s for star and column completion", async (source, column) => {
|
||||
const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" });
|
||||
store.listCompletionColumns
|
||||
.mockResolvedValueOnce([{ name: "PUBLIC_ID", table: "Alias" }])
|
||||
.mockResolvedValueOnce([{ name: "PRIVATE_ID", table: "Alias", schema: "APP" }])
|
||||
.mockResolvedValueOnce([{ name: "QUOTED_ID", table: "Alias" }]);
|
||||
await metadata.ensureColumnsForTable({ name: "Alias" }, { nameQuoted: false });
|
||||
await metadata.ensureColumnsForTable({ name: "Alias", schema: "APP" });
|
||||
await metadata.ensureColumnsForTable({ name: "Alias" }, { nameQuoted: true });
|
||||
expect(store.listCompletionColumns).toHaveBeenCalledTimes(3);
|
||||
const sql = `SELECT s. FROM ${source} s`;
|
||||
const context = getSqlCompletionContext(sql, 9, { databaseType: "oceanbase-oracle" });
|
||||
expect(buildSelectStarExpansion(context, metadata.cachedColumnsByTable, "oracle", context.qualifier, "oceanbase-oracle")).toBe(column);
|
||||
const items = buildSqlCompletionItems(sql, 9, { tables: [], columnsByTable: metadata.cachedColumnsByTable, databaseType: "oceanbase-oracle", currentSchema: "APP" });
|
||||
expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual([column]);
|
||||
expect(items.find((item) => item.label === "s.*")?.apply).toBe(column);
|
||||
});
|
||||
|
||||
it.each(["Alias", "APP.Alias", '"Alias"'])("uses the same OceanBase cache identity for diagnostics on %s", async (source) => {
|
||||
const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" });
|
||||
store.listCompletionColumns.mockResolvedValue([{ name: "ID", table: "Alias" }]);
|
||||
const sql = `SELECT s.MISSING FROM ${source} s`;
|
||||
const reference = getSqlCompletionContext(sql, 9, { databaseType: "oceanbase-oracle" }).referencedTables[0]!;
|
||||
await metadata.ensureColumnsForTable(reference);
|
||||
const tableStart = sql.indexOf(source);
|
||||
const diagnostics = buildSqlSemanticDiagnostics(
|
||||
{
|
||||
tables: [{ name: reference.name, schema: reference.schema, alias: "s", span: { start_line: 1, start_column: tableStart + 1, end_line: 1, end_column: tableStart + source.length } }],
|
||||
columns: [{ name: "MISSING", qualifier: "s", span: { start_line: 1, start_column: 8, end_line: 1, end_column: 16 } }],
|
||||
},
|
||||
{ tables: [], columnsByTable: metadata.cachedColumnsByTable, loadedColumnTables: metadata.loadedColumnsByTable, sql, databaseType: "oceanbase-oracle", currentSchema: "APP" },
|
||||
);
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual(["Unknown column s.MISSING"]);
|
||||
});
|
||||
|
||||
it("uses scoped synonym columns for JOIN condition completion", async () => {
|
||||
const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" });
|
||||
store.listCompletionColumns.mockResolvedValue([{ name: "TENANT_ID", table: "Alias" }]);
|
||||
await metadata.ensureColumnsForTable({ name: "Alias" });
|
||||
await metadata.ensureColumnsForTable({ name: "Alias", schema: "APP" });
|
||||
const sql = "SELECT * FROM Alias a JOIN APP.Alias b ON ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, { tables: [], columnsByTable: metadata.cachedColumnsByTable, databaseType: "oceanbase-oracle" });
|
||||
expect(items.map((item) => item.label)).toContain("a.TENANT_ID = b.TENANT_ID");
|
||||
});
|
||||
|
||||
it("keeps foreign-key JOIN suggestions available under the same scoped cache keys", () => {
|
||||
const { metadata } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" });
|
||||
metadata.cachedForeignKeysByTable.set(metadata.completionCacheKey({ name: "Alias" }), [{ name: "FK_PARENT", column: "PARENT", ref_table: "OtherAlias", ref_column: "ID" }]);
|
||||
const sql = "SELECT * FROM Alias a JOIN OtherAlias b ON ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, { tables: [], columnsByTable: metadata.cachedColumnsByTable, foreignKeysByTable: metadata.cachedForeignKeysByTable, databaseType: "oceanbase-oracle" });
|
||||
expect(items.map((item) => item.label)).toContain("a.PARENT = b.ID");
|
||||
});
|
||||
|
||||
it("keeps an OceanBase selected schema separate from an explicitly qualified synonym", async () => {
|
||||
const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "MixedOwner" });
|
||||
store.listCompletionColumns.mockResolvedValue(columns);
|
||||
await metadata.ensureColumnsForTable({ name: "Alias" });
|
||||
expect(store.listCompletionColumns).toHaveBeenLastCalledWith("connection", "demo", "Alias", undefined, expect.objectContaining({ currentSchema: "MixedOwner" }), undefined);
|
||||
await metadata.ensureColumnsForTable({ name: "Alias", schema: "MixedOwner" });
|
||||
expect(store.listCompletionColumns).toHaveBeenLastCalledWith("connection", "demo", "Alias", "MixedOwner", expect.objectContaining({ currentSchema: "MixedOwner" }), undefined);
|
||||
});
|
||||
|
||||
it.each(["getEditorSqlCompletionContext", "getEditorSemanticModel"] as const)("keys %s by document, editor state, position and dialect", (method) => {
|
||||
const { metadata, props } = createHarness();
|
||||
const state = EditorState.create({ doc: props.modelValue });
|
||||
|
||||
+24
-11
@@ -42,6 +42,16 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
const view = shallowRef<EditorView | null>(currentView);
|
||||
const runtime: Options["runtime"] = { editorIsActive: true, executableStatementRangeCache: null, setSqlDiagnosticsEffect: StateEffect.define<SqlSemanticDiagnostic[]>(), diagnosticComp: null, buildSqlDiagnosticExtension: null, codeMirrorCompletionStatus: () => null };
|
||||
const settingsStore = { editorSettings: { sqlSemanticDiagnosticsEnabled: true } } as Options["settingsStore"];
|
||||
const metadata = {
|
||||
cachedTables: [],
|
||||
cachedColumnsByTable: new Map(),
|
||||
loadedColumnsByTable: new Set<string>(),
|
||||
usesOracleSessionCompletionColumns: () => false,
|
||||
findExactSemanticDiagnosticTable: vi.fn().mockResolvedValue(null),
|
||||
completionCacheKey: (table: { name: string }) => table.name,
|
||||
ensureColumnsForTable: vi.fn().mockResolvedValue(false),
|
||||
isMissingTableMetadataError: () => false,
|
||||
};
|
||||
let diagnostics!: ReturnType<typeof useQueryEditorDiagnostics>;
|
||||
const host = document.createElement("div");
|
||||
const app = createApp({
|
||||
@@ -57,16 +67,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
sqlBehaviorDialect: () => props.dialect,
|
||||
semanticCompletionEnabled: false,
|
||||
maxCompletionTables: 100,
|
||||
metadata: {
|
||||
cachedTables: [],
|
||||
cachedColumnsByTable: new Map(),
|
||||
loadedColumnsByTable: new Set(),
|
||||
usesOracleSessionCompletionColumns: () => false,
|
||||
findExactSemanticDiagnosticTable: vi.fn().mockResolvedValue(null),
|
||||
completionCacheKey: (table) => table.name,
|
||||
ensureColumnsForTable: vi.fn().mockResolvedValue(false),
|
||||
isMissingTableMetadataError: () => false,
|
||||
},
|
||||
metadata,
|
||||
});
|
||||
return () => h("div");
|
||||
},
|
||||
@@ -83,10 +84,22 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
currentView.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
return { diagnostics, props, runtime, settingsStore, currentView, unmount };
|
||||
return { diagnostics, props, runtime, settingsStore, currentView, 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
|
||||
|
||||
+41
-2
@@ -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")'));
|
||||
|
||||
Vendored
+109
@@ -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();
|
||||
}
|
||||
});
|
||||
apps/desktop/src/components/objects/__tests__/ObjectBrowser.oceanbaseCommentSearch.component.spec.ts
Vendored
+6
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Vendored
+224
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+57
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+100
@@ -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
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4670,6 +4670,8 @@ export default withEnglishFallback({
|
||||
schema: "スキーマ",
|
||||
search: "テーブル、ビュー、関数、シーケンス、プロシージャを検索...",
|
||||
searchCollections: "コレクションを検索...",
|
||||
pagedRegexUnsupported: "ページ分割されたオブジェクト検索では正規表現を使用できません。名前またはコメントを入力してください。",
|
||||
pagedOrder: "データベースの順序で {count} 個のオブジェクトを読み込みました。オブジェクトを変更した後は更新してください。",
|
||||
loading: "オブジェクトを読み込み中...",
|
||||
empty: "オブジェクトが見つかりません",
|
||||
selectedTables: "{count}テーブル選択中",
|
||||
|
||||
@@ -4668,6 +4668,8 @@ export default withEnglishFallback({
|
||||
schema: "스키마",
|
||||
search: "테이블, 뷰, 함수, 시퀀스 또는 프로시저 검색...",
|
||||
searchCollections: "컬렉션 검색...",
|
||||
pagedRegexUnsupported: "페이지 단위 객체 검색은 정규식을 지원하지 않습니다. 이름 또는 주석을 입력하세요.",
|
||||
pagedOrder: "객체 {count}개를 데이터베이스 순서대로 불러왔습니다. 객체 변경 후 새로 고치세요.",
|
||||
loading: "객체를 불러오는 중...",
|
||||
empty: "객체가 없습니다",
|
||||
selectedTables: "테이블 {count}개 선택됨",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5147,6 +5147,8 @@ export default withEnglishFallback({
|
||||
schema: "Схема",
|
||||
search: "Поиск таблиц, представлений, функций, последовательностей или процедур...",
|
||||
searchCollections: "Поиск коллекций...",
|
||||
pagedRegexUnsupported: "Постраничный поиск объектов не поддерживает регулярные выражения. Введите имя или текст комментария.",
|
||||
pagedOrder: "Загружено объектов: {count}, в порядке базы данных. После изменения объектов обновите список.",
|
||||
loading: "Загрузка объектов...",
|
||||
empty: "Объекты не найдены",
|
||||
selectedTables: "Выбрано таблиц: {count}",
|
||||
|
||||
@@ -5344,6 +5344,8 @@ export default withEnglishFallback({
|
||||
schema: "Schema",
|
||||
search: "搜索表、视图、函数、序列或存储过程...",
|
||||
searchCollections: "搜索集合...",
|
||||
pagedRegexUnsupported: "分页对象搜索暂不支持正则表达式,请输入名称或注释文本。",
|
||||
pagedOrder: "已加载 {count} 个对象,按数据库顺序排列。对象变更后请刷新。",
|
||||
loading: "正在加载对象...",
|
||||
empty: "没有找到对象",
|
||||
selectedTables: "已选择 {count} 张表",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -398,18 +398,20 @@ pub async fn get_columns(
|
||||
table: String,
|
||||
catalog: Option<String>,
|
||||
client_session_id: Option<String>,
|
||||
current_schema: Option<String>,
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await {
|
||||
return dbx_core::schema::get_doris_catalog_columns_core(&state, &connection_id, &catalog, &database, &table)
|
||||
.await;
|
||||
}
|
||||
dbx_core::schema::get_columns_core_for_session(
|
||||
dbx_core::schema::get_columns_core_for_session_in_context(
|
||||
&state,
|
||||
&connection_id,
|
||||
&database,
|
||||
&schema,
|
||||
&table,
|
||||
client_session_id.as_deref(),
|
||||
current_schema.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -291,6 +291,10 @@ pub struct ColumnInfo {
|
||||
pub data_type: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_schema: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_table: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_object_type: Option<String>,
|
||||
pub is_nullable: bool,
|
||||
pub column_default: Option<String>,
|
||||
pub is_primary_key: bool,
|
||||
@@ -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());
|
||||
}
|
||||
@@ -27,6 +27,7 @@ pub struct SchemaQuery {
|
||||
pub table_name_filter: Option<String>,
|
||||
pub apply_visible_filter: Option<bool>,
|
||||
pub client_session_id: Option<String>,
|
||||
pub current_schema: Option<String>,
|
||||
pub include_postgres_access: Option<bool>,
|
||||
pub portable: Option<bool>,
|
||||
pub execution_id: Option<String>,
|
||||
@@ -484,8 +485,8 @@ where
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn should_cache_columns(client_session_id: Option<&str>) -> bool {
|
||||
client_session_id.is_none()
|
||||
fn should_cache_columns(client_session_id: Option<&str>, current_schema: Option<&str>) -> bool {
|
||||
client_session_id.is_none() && current_schema.is_none()
|
||||
}
|
||||
|
||||
pub async fn get_plugin_table_metadata(
|
||||
@@ -511,19 +512,20 @@ pub async fn list_columns(
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
} else {
|
||||
dbx_core::schema::get_columns_core_for_session(
|
||||
dbx_core::schema::get_columns_core_for_session_in_context(
|
||||
&state.app,
|
||||
&q.connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
q.client_session_id.as_deref(),
|
||||
q.current_schema.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
};
|
||||
let result = if should_cache_columns(q.client_session_id.as_deref()) {
|
||||
let result = if should_cache_columns(q.client_session_id.as_deref(), q.current_schema.as_deref()) {
|
||||
cached_metadata(
|
||||
&state.app,
|
||||
&q.connection_id,
|
||||
@@ -979,9 +981,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn session_scoped_columns_bypass_persistent_cache() {
|
||||
assert!(should_cache_columns(None));
|
||||
assert!(!should_cache_columns(Some("oracle-session")));
|
||||
assert!(!should_cache_columns(Some("")));
|
||||
assert!(should_cache_columns(None, None));
|
||||
assert!(!should_cache_columns(Some("oracle-session"), None));
|
||||
assert!(!should_cache_columns(Some(""), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_schema_columns_bypass_cache_without_changing_explicit_schema() {
|
||||
let query: SchemaQuery = serde_json::from_value(serde_json::json!({
|
||||
"connection_id": "ob", "database": "LOGIN", "schema": "", "table": "ORDERS_ALIAS",
|
||||
"current_schema": "SelectedOwner"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(query.schema.as_deref(), Some(""));
|
||||
assert_eq!(query.current_schema.as_deref(), Some("SelectedOwner"));
|
||||
assert!(!should_cache_columns(query.client_session_id.as_deref(), query.current_schema.as_deref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user