Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public interface ConnectionManager {

void setDatabaseContext(String database);

<T> T withDatabaseContext(String database, DatabaseContextAction<T> action) throws SQLException;

Datasource getDatasource();

DBConnectInfo getConnectInfo();
Expand All @@ -37,4 +39,9 @@ public interface ConnectionManager {
String getDatabaseContextKey();

void close();

@FunctionalInterface
interface DatabaseContextAction<T> {
T run() throws SQLException;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ public interface ResourceBrowser {

List<TreeNode> getChildren(TreeNode node, boolean fromCache) throws SQLException;

List<Schema> getSchemas() throws SQLException;

List<Table> getTables(String schema) throws SQLException;

List<View> getViews(String schema) throws SQLException;

List<Field> getFields(String schema, String table) throws SQLException;

List<Schema> getSchemas(SQL sql) throws SQLException;

List<Table> getTables(SQL sql) throws SQLException;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,26 @@ public Driver getDriver() {

@Override
public void setDatabaseContext(String database) {
this.currentDatabase.set(database);
if (StringUtils.isBlank(database)) {
this.currentDatabase.remove();
} else {
this.currentDatabase.set(database);
}
}

@Override
public <T> T withDatabaseContext(String database, DatabaseContextAction<T> action) throws SQLException {
var previousDatabase = this.currentDatabase.get();
try {
this.setDatabaseContext(database);
return action.run();
} finally {
if (previousDatabase == null) {
this.currentDatabase.remove();
} else {
this.currentDatabase.set(previousDatabase);
}
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.jumpserver.chen.framework.datasource.metadata;

public record QualifiedRelation(
String catalog,
String schema,
String name,
String kind
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.jumpserver.chen.framework.datasource.metadata;

import java.util.List;

public record RelationColumnsMetadata(
QualifiedRelation relation,
List<SqlColumnMetadata> columns
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.jumpserver.chen.framework.datasource.metadata;

import java.util.List;

public record RelationMetadataPage(
List<QualifiedRelation> items,
boolean truncated
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.jumpserver.chen.framework.datasource.metadata;

public record SqlColumnMetadata(
String name,
String dataType,
boolean nullable
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
package org.jumpserver.chen.framework.datasource.metadata;

import org.apache.commons.lang3.StringUtils;
import org.jumpserver.chen.framework.datasource.ConnectionManager;
import org.jumpserver.chen.framework.datasource.ResourceBrowser;
import org.jumpserver.chen.framework.datasource.entity.resource.ResourceNodeSnapshot;
import org.jumpserver.chen.framework.utils.SqlIdentifierUtils;

import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

public class SqlMetadataCatalog {
public static final int DEFAULT_RELATION_LIMIT = 100;
public static final int MAX_RELATION_LIMIT = 200;
public static final int MAX_COLUMN_RELATIONS = 20;

private static final Set<String> RELATION_KINDS = Set.of("table", "view");

private final ResourceBrowser resourceBrowser;
private final ConnectionManager connectionManager;

public SqlMetadataCatalog(ResourceBrowser resourceBrowser, ConnectionManager connectionManager) {
this.resourceBrowser = Objects.requireNonNull(resourceBrowser);
this.connectionManager = Objects.requireNonNull(connectionManager);
}

public RelationMetadataPage listRelations(ResourceNodeSnapshot node, String context, String prefix, Integer limit)
throws SQLException {
var scope = connectionManager.withDatabaseContext(null, () -> this.resolveScope(node, context));
return connectionManager.withDatabaseContext(
scope.catalog(),
() -> this.listRelations(scope, prefix, limit)
);
}

private RelationMetadataPage listRelations(MetadataScope scope, String prefix, Integer limit) throws SQLException {
var schemas = this.resolveSchemas(scope.schema());
var relations = new ArrayList<QualifiedRelation>();

for (var schema : schemas) {
resourceBrowser.getTables(schema).forEach(table -> relations.add(
new QualifiedRelation(scope.catalog(), schema, table.getName(), "table")
));
resourceBrowser.getViews(schema).forEach(view -> relations.add(
new QualifiedRelation(scope.catalog(), schema, view.getName(), "view")
));
}

var normalizedPrefix = StringUtils.defaultString(prefix).trim().toLowerCase(Locale.ROOT);
var filtered = relations.stream()
.filter(relation -> normalizedPrefix.isEmpty()
|| relation.name().toLowerCase(Locale.ROOT).startsWith(normalizedPrefix)
|| (relation.schema() + "." + relation.name()).toLowerCase(Locale.ROOT)
.startsWith(normalizedPrefix))
.sorted(Comparator.comparing(QualifiedRelation::schema, String.CASE_INSENSITIVE_ORDER)
.thenComparing(QualifiedRelation::name, String.CASE_INSENSITIVE_ORDER)
.thenComparing(QualifiedRelation::kind))
.toList();

var boundedLimit = limit == null
? DEFAULT_RELATION_LIMIT
: Math.max(1, Math.min(limit, MAX_RELATION_LIMIT));
return new RelationMetadataPage(
List.copyOf(filtered.subList(0, Math.min(filtered.size(), boundedLimit))),
filtered.size() > boundedLimit
);
}

public List<RelationColumnsMetadata> listColumns(
ResourceNodeSnapshot node,
String context,
List<QualifiedRelation> requestedRelations
) throws SQLException {
if (requestedRelations == null || requestedRelations.isEmpty()) {
return List.of();
}
if (requestedRelations.size() > MAX_COLUMN_RELATIONS) {
throw new IllegalArgumentException("Too many relations in one metadata request");
}

var scope = connectionManager.withDatabaseContext(null, () -> this.resolveScope(node, context));
return connectionManager.withDatabaseContext(
scope.catalog(),
() -> this.listColumns(scope, requestedRelations)
);
}

private List<RelationColumnsMetadata> listColumns(
MetadataScope scope,
List<QualifiedRelation> requestedRelations
) throws SQLException {
var availableSchemas = this.resolveSchemas(null);
var relationsBySchema = new LinkedHashMap<String, Map<RelationKey, QualifiedRelation>>();
var canonicalRequests = new LinkedHashMap<RelationKey, QualifiedRelation>();

for (var requested : requestedRelations) {
this.validateRequestedRelation(requested, scope.catalog());
var requestedSchema = StringUtils.defaultIfBlank(requested.schema(), scope.schema());
var canonicalSchema = this.resolveCanonicalIdentifier(
availableSchemas,
requestedSchema,
"Unknown relation schema"
);

var availableRelations = relationsBySchema.get(canonicalSchema);
if (availableRelations == null) {
availableRelations = this.loadRelationsByKey(scope.catalog(), canonicalSchema);
relationsBySchema.put(canonicalSchema, availableRelations);
}
var key = new RelationKey(canonicalSchema, requested.name(), requested.kind());
var canonical = availableRelations.get(key);
if (canonical == null) {
var canonicalName = this.resolveCanonicalIdentifier(
availableRelations.values().stream()
.filter(relation -> relation.kind().equals(requested.kind()))
.map(QualifiedRelation::name)
.toList(),
requested.name(),
"Unknown relation"
);
canonical = availableRelations.get(new RelationKey(canonicalSchema, canonicalName, requested.kind()));
}
var canonicalKey = new RelationKey(canonical.schema(), canonical.name(), canonical.kind());
canonicalRequests.putIfAbsent(canonicalKey, canonical);
}

var result = new ArrayList<RelationColumnsMetadata>();
for (var entry : canonicalRequests.entrySet()) {
var relation = entry.getValue();
var columns = resourceBrowser.getFields(relation.schema(), relation.name()).stream()
.map(field -> new SqlColumnMetadata(field.getName(), field.getType(), field.isNullable()))
.toList();
result.add(new RelationColumnsMetadata(relation, columns));
}
return List.copyOf(result);
}

private MetadataScope resolveScope(ResourceNodeSnapshot node, String context) throws SQLException {
if (node == null) {
throw new IllegalArgumentException("Invalid metadata context");
}

var contextKey = connectionManager.getContextKey();
var databaseContextKey = connectionManager.getDatabaseContextKey();
var currentContext = StringUtils.defaultString(context).trim();
var catalog = node.database();

if (StringUtils.equals(contextKey, databaseContextKey) && StringUtils.isNotBlank(currentContext)) {
var allowedContexts = connectionManager.getSqlActuator().getSchemas();
if (!allowedContexts.contains(currentContext)) {
throw new IllegalArgumentException("Unknown database metadata context");
}
catalog = currentContext;
}
SqlIdentifierUtils.validateDatabaseName(catalog);

String schema = null;
if (StringUtils.equals(contextKey, "schema")) {
schema = StringUtils.defaultIfBlank(currentContext, node.schema());
if (StringUtils.isNotBlank(catalog) && schema.startsWith(catalog + ".")) {
schema = schema.substring(catalog.length() + 1);
}
} else if (StringUtils.equals(node.database(), catalog)) {
schema = node.schema();
}
return new MetadataScope(catalog, schema);
}

private List<String> resolveSchemas(String requestedSchema) throws SQLException {
var schemas = resourceBrowser.getSchemas().stream().map(schema -> schema.getName()).toList();
if (StringUtils.isBlank(requestedSchema)) {
return schemas;
}
return List.of(this.resolveCanonicalIdentifier(schemas, requestedSchema, "Unknown metadata schema"));
}

private String resolveCanonicalIdentifier(
Collection<String> candidates,
String requested,
String unknownMessage
) {
if (StringUtils.isBlank(requested)) {
throw new IllegalArgumentException(unknownMessage);
}

var exactMatches = candidates.stream().filter(requested::equals).distinct().toList();
if (exactMatches.size() == 1) {
return exactMatches.get(0);
}

var caseRule = this.identifierCaseRule();
if (caseRule == IdentifierCaseRule.LOWER || caseRule == IdentifierCaseRule.UPPER) {
var normalizedRequested = caseRule.normalize(requested);
var normalizedMatches = candidates.stream()
.filter(candidate -> candidate.equals(caseRule.normalize(candidate)))
.filter(candidate -> candidate.equals(normalizedRequested))
.distinct()
.toList();
if (normalizedMatches.size() == 1) {
return normalizedMatches.get(0);
}
throw new IllegalArgumentException(unknownMessage);
}

if (caseRule == IdentifierCaseRule.INSENSITIVE) {
var insensitiveMatches = candidates.stream()
.filter(candidate -> candidate.equalsIgnoreCase(requested))
.distinct()
.toList();
if (insensitiveMatches.size() == 1) {
return insensitiveMatches.get(0);
}
}
throw new IllegalArgumentException(unknownMessage);
}

private IdentifierCaseRule identifierCaseRule() {
var connectInfo = connectionManager.getConnectInfo();
var dbType = connectInfo == null ? "" : StringUtils.defaultString(connectInfo.getDbType());
return switch (dbType.toLowerCase(Locale.ROOT)) {
case "postgresql" -> IdentifierCaseRule.LOWER;
case "oracle", "db2", "dm", "dameng" -> IdentifierCaseRule.UPPER;
case "mysql", "mariadb", "sqlserver" -> IdentifierCaseRule.INSENSITIVE;
default -> IdentifierCaseRule.EXACT;
};
}

private Map<RelationKey, QualifiedRelation> loadRelationsByKey(String catalog, String schema) throws SQLException {
var result = new LinkedHashMap<RelationKey, QualifiedRelation>();
resourceBrowser.getTables(schema).forEach(table -> {
var relation = new QualifiedRelation(catalog, schema, table.getName(), "table");
result.put(new RelationKey(schema, relation.name(), relation.kind()), relation);
});
resourceBrowser.getViews(schema).forEach(view -> {
var relation = new QualifiedRelation(catalog, schema, view.getName(), "view");
result.put(new RelationKey(schema, relation.name(), relation.kind()), relation);
});
return result;
}

private void validateRequestedRelation(QualifiedRelation relation, String catalog) {
if (relation == null || StringUtils.isBlank(relation.name()) || !RELATION_KINDS.contains(relation.kind())) {
throw new IllegalArgumentException("Invalid relation metadata request");
}
if (StringUtils.isNotBlank(relation.catalog()) && StringUtils.isNotBlank(catalog)
&& !relation.catalog().equals(catalog)) {
throw new IllegalArgumentException("Relation catalog does not match the active context");
}
}

private record MetadataScope(String catalog, String schema) {
}

private record RelationKey(String schema, String name, String kind) {
}

private enum IdentifierCaseRule {
LOWER,
UPPER,
INSENSITIVE,
EXACT;

private String normalize(String identifier) {
return this == UPPER ? identifier.toUpperCase(Locale.ROOT) : identifier.toLowerCase(Locale.ROOT);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public List<View> getViews(String schema) throws SQLException {
return this.getViews(SQL.of(SQL_GET_VIEWS, schema));
}

private static final String SQL_GET_FIELDS = "SELECT COLUMN_NAME AS NAME, COLUMN_TYPE AS TYPE, COLUMN_KEY AS `KEY`, IS_NULLABLE AS `NULLABLE`, COLUMN_DEFAULT AS `DEFAULT`, EXTRA AS EXTRA, COLUMN_COMMENT AS COMMENT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '?' AND TABLE_NAME = '?'";
private static final String SQL_GET_FIELDS = "SELECT COLUMN_NAME AS NAME, DATA_TYPE AS TYPE, IS_NULLABLE AS NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '?' AND TABLE_NAME = '?'";

@Override
public List<Field> getFields(String schema, String table) throws SQLException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public List<View> getViews(String schema) throws SQLException {
return this.getViews(SQL.of(SQL_GET_VIEWS, schema));
}

private static final String SQL_GET_FIELDS = "SELECT COLUMN_NAME AS `NAME`,DATA_TYPE AS `TYPE`,DATA_LENGTH AS `LENGTH`,DATA_DEFAULT AS `DEFAULT` FROM ALL_TAB_COLUMNS WHERE OWNER='?' AND TABLE_NAME='?'";
private static final String SQL_GET_FIELDS = "SELECT COLUMN_NAME AS NAME,DATA_TYPE AS TYPE,NULLABLE AS NULLABLE FROM ALL_TAB_COLUMNS WHERE OWNER='?' AND TABLE_NAME='?'";

@Override
public List<Field> getFields(String schema, String table) throws SQLException {
Expand Down
Loading