diff --git a/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls b/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls index 3155e9175ed..b25b03eb2a6 100644 --- a/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls @@ -72,6 +72,19 @@ input SQLDataFilter { orderBy: String } +type SQLResultReference @since(version: "26.1.1") { + "Name of the association (foreign key)" + associationName: String! + "Fully qualified name of the entity" + targetEntityName: String + "True for reverse references (other entities' FKs pointing at the source entity); false for forward foreign keys" + isReference: Boolean! + "Navigator node URI of the target entity, suitable for opening it directly in the UI. Null if the entity is not present in the navigator tree" + nodePath: String + "Indices of result-set columns that participate as the source-side attributes of this reference" + columnIndexList: [Int!]! +} + type SQLResultColumn { position: Int! name: String @@ -122,6 +135,9 @@ type SQLResultSet { "Returns list of rows in the result set. Each row contains data and metadata" rowsWithMetaData: [SQLResultRowMetaData] @since(version: "23.3.5") + "All FK navigation paths available from this result set, both forward (outgoing) and reverse (incoming). Distinguish via SQLResultReference.isReference" + references: [SQLResultReference!]! @since(version: "26.1.0") + # True means that resultset was generated by single entity query # New rows can be added, old rows can be deleted singleEntity: Boolean! @@ -451,6 +467,19 @@ extend type Mutation { dataFormat: ResultDataFormat ): AsyncTaskInfo! + "Creates async task for navigating a foreign-key reference exposed in SQLResultSet.references. Pass associationName, isReference and any one of the chosen reference's columnIndices (used to locate the source entity)." + asyncSqlNavigateForeignKey( + projectId: ID, + connectionId: ID!, + contextId: ID!, + resultsId: ID!, + row: SQLResultRow!, + columnIndex: Int!, + associationName: String!, + isReference: Boolean!, + dataFormat: ResultDataFormat + ): AsyncTaskInfo! @since(version: "26.1.1") + "Returns transaction log info for the specified project, connection and context" getTransactionLogInfo( projectId: ID!, diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java index 42b552084d9..73bf603143d 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java @@ -126,6 +126,17 @@ WebAsyncTaskInfo asyncReadDataFromContainer( @Nullable WebSQLDataFilter filter, @Nullable WebDataFormat dataFormat) throws DBWebException; + @WebAction + WebAsyncTaskInfo asyncNavigateForeignKey( + @NotNull WebSession webSession, + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull WebSQLResultsRow row, + int columnIndex, + @NotNull String associationName, + boolean isReference, + @Nullable WebDataFormat dataFormat) throws DBException; + /** * Reads dynamic trace from provided database results. */ diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java index df4835bb5d9..d612d74298c 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java @@ -480,7 +480,7 @@ public WebSQLExecuteInfo updateResultsDataBatch( WebSQLQueryResultSet updatedResultSet = new WebSQLQueryResultSet(); updatedResultSet.setResultsInfo(resultsInfo); - updatedResultSet.setColumns(resultsInfo.getAttributes()); + updatedResultSet.setColumns(webSession, resultsInfo.getAttributes()); WebSQLQueryResults updateResults = new WebSQLQueryResults(webSession, dataFormat); updateResults.setUpdateRowCount(totalUpdateCount); @@ -652,7 +652,7 @@ private DBSDataManipulator generateUpdateResultsDataBatch( WebSQLQueryResultSet updatedResultSet = new WebSQLQueryResultSet(); updatedResultSet.setResultsInfo(resultsInfo); - updatedResultSet.setColumns(resultsInfo.getAttributes()); + updatedResultSet.setColumns(webSession, resultsInfo.getAttributes()); if (!CommonUtils.isEmpty(updatedRows)) { diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryDataReceiver.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryDataReceiver.java index bfb82f93d36..da7138a420f 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryDataReceiver.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryDataReceiver.java @@ -170,7 +170,7 @@ public void fetchEnd(@NotNull DBCSession session, @NotNull DBCResultSet resultSe } } - webResultSet.setColumns(bindings); + webResultSet.setColumns(webSession, bindings); webResultSet.setRows(List.of(rows.toArray(new WebSQLQueryResultSetRow[0]))); webResultSet.setHasChildrenCollection(resultSet instanceof DBDSubCollectionResultSet); webResultSet.setSupportsDataFilter(dataContainer.isFeatureSupported(DBSDataContainer.FEATURE_DATA_FILTER)); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultColumn.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultColumn.java index a3a5778b3cf..aa9489960e6 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultColumn.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultColumn.java @@ -1,6 +1,6 @@ /* * DBeaver - Universal Database Manager - * Copyright (C) 2010-2024 DBeaver Corp and others + * Copyright (C) 2010-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultReference.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultReference.java new file mode 100644 index 00000000000..469b23c8c5b --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultReference.java @@ -0,0 +1,117 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.sql; + +import io.cloudbeaver.model.session.WebSession; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.DBPEvaluationContext; +import org.jkiss.dbeaver.model.DBUtils; +import org.jkiss.dbeaver.model.meta.Property; +import org.jkiss.dbeaver.model.navigator.DBNNode; +import org.jkiss.dbeaver.model.struct.DBSEntity; +import org.jkiss.dbeaver.model.struct.DBSEntityAssociation; +import org.jkiss.dbeaver.model.struct.DBSEntityConstraint; + +import java.util.List; + +public class WebSQLQueryResultReference { + + private static final Log log = Log.getLog(WebSQLQueryResultReference.class); + + @Nullable + private final WebSession session; + @NotNull + private final DBSEntityAssociation association; + private final boolean reverse; + @NotNull + private final List columnIndexList; + + public WebSQLQueryResultReference( + @Nullable WebSession session, + @NotNull DBSEntityAssociation association, + boolean reverse, + @NotNull List columnIndexList + ) { + this.session = session; + this.association = association; + this.reverse = reverse; + this.columnIndexList = columnIndexList; + } + + @NotNull + @Property + public String getAssociationName() { + return association.getName(); + } + + @Property + public boolean isReference() { + return reverse; + } + + @Nullable + @Property + public String getTargetEntityName() { + DBSEntity targetEntity = getTargetEntity(); + if (targetEntity == null) { + return null; + } + return DBUtils.getObjectFullName(targetEntity, DBPEvaluationContext.UI); + } + + + @Nullable + @Property + public String getNodePath() { + if (session == null) { + return null; + } + DBSEntity targetEntity = getTargetEntity(); + if (targetEntity == null) { + return null; + } + try { + DBNNode node = session.getNavigatorModelOrThrow() + .getNodeByObject(session.getProgressMonitor(), targetEntity, false); + return node == null ? null : node.getNodeUri(); + } catch (DBException e) { + log.debug("Error resolving navigator node for entity " + targetEntity.getName(), e); + return null; + } + } + + @NotNull + @Property + public List getColumnIndexList() { + return columnIndexList; + } + + @Nullable + private DBSEntity getTargetEntity() { + if (reverse) { + return association.getParentObject(); + } + DBSEntityConstraint referencedConstraint = association.getReferencedConstraint(); + if (referencedConstraint == null) { + return null; + } + return referencedConstraint.getParentObject(); + } +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultSet.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultSet.java index 3b3291a91b3..a7ce4459277 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultSet.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultSet.java @@ -1,6 +1,6 @@ /* * DBeaver - Universal Database Manager - * Copyright (C) 2010-2024 DBeaver Corp and others + * Copyright (C) 2010-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ package io.cloudbeaver.service.sql; +import io.cloudbeaver.model.session.WebSession; import io.cloudbeaver.service.sql.WebSQLResultSetRowIdentifier.WebSQLResultSetRowIdentifierState; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; @@ -36,6 +37,7 @@ public class WebSQLQueryResultSet { private static final Log log = Log.getLog(WebSQLQueryResultSet.class); private WebSQLQueryResultColumn[] columns; + private List references = Collections.emptyList(); private List rows = Collections.emptyList(); private boolean hasMoreData; private WebSQLResultsInfo resultsInfo; @@ -67,12 +69,19 @@ public void setColumns(WebSQLQueryResultColumn[] columns) { this.columns = columns; } - public void setColumns(DBDAttributeBinding[] bindings) { + public void setColumns(@NotNull WebSession session, @NotNull DBDAttributeBinding[] bindings) { WebSQLQueryResultColumn[] columns = new WebSQLQueryResultColumn[bindings.length]; for (int i = 0; i < bindings.length; i++) { columns[i] = new WebSQLQueryResultColumn(bindings[i]); } this.columns = columns; + this.references = WebSQLUtils.collectReferences(session, bindings); + } + + @NotNull + @Property + public List getReferences() { + return references; } @Property diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java index 32ddbfcdb08..6bf4437892f 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java @@ -1,6 +1,6 @@ /* * DBeaver - Universal Database Manager - * Copyright (C) 2010-2024 DBeaver Corp and others + * Copyright (C) 2010-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,17 +26,18 @@ import io.cloudbeaver.utils.ServletAppUtils; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.Log; import org.jkiss.dbeaver.model.DBPEvaluationContext; import org.jkiss.dbeaver.model.data.*; import org.jkiss.dbeaver.model.exec.DBCException; import org.jkiss.dbeaver.model.exec.DBCSession; +import org.jkiss.dbeaver.model.exec.DBExecUtils; import org.jkiss.dbeaver.model.gis.DBGeometry; import org.jkiss.dbeaver.model.gis.GisConstants; import org.jkiss.dbeaver.model.gis.GisTransformUtils; import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; -import org.jkiss.dbeaver.model.struct.DBSAttributeBase; -import org.jkiss.dbeaver.model.struct.DBSTypedObject; +import org.jkiss.dbeaver.model.struct.*; import org.jkiss.dbeaver.model.websocket.event.WSEvent; import org.jkiss.dbeaver.utils.ContentUtils; import org.jkiss.dbeaver.utils.GeneralUtils; @@ -50,6 +51,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.Function; /** * Web SQL utils. @@ -329,4 +331,84 @@ public static T requestConfirmation( webSession.removeAttribute(attributeName); } } + + @NotNull + public static List collectReferences( + @NotNull WebSession session, + @NotNull DBDAttributeBinding[] bindings + ) { + Map attrToIndex = new HashMap<>(); + LinkedHashSet entities = new LinkedHashSet<>(); + for (int i = 0; i < bindings.length; i++) { + DBSEntityAttribute ea = bindings[i].getEntityAttribute(); + if (ea == null) { + continue; + } + attrToIndex.putIfAbsent(ea, i); + DBSEntity parent = ea.getParentObject(); + entities.add(parent); + } + + Function attrToBinding = attr -> { + Integer idx = attrToIndex.get(attr); + return idx == null ? null : bindings[idx]; + }; + + List result = new ArrayList<>(); + DBRProgressMonitor monitor = session.getProgressMonitor(); + for (DBSEntity entity : entities) { + try { + for (DBSEntityAssociation fk : DBExecUtils.readAssociations(monitor, entity, attrToBinding)) { + List columnIndex = collectOwnColumnIndex(monitor, fk, false, attrToIndex); + if (columnIndex != null) { + result.add(new WebSQLQueryResultReference(session, fk, false, columnIndex)); + } + } + for (DBSEntityAssociation ref : DBExecUtils.readReferences(monitor, entity, attrToBinding)) { + List columnIndex = collectOwnColumnIndex(monitor, ref, true, attrToIndex); + if (columnIndex != null) { + result.add(new WebSQLQueryResultReference(session, ref, true, columnIndex)); + } + } + } catch (DBException e) { + log.debug("Error collecting references for entity " + entity.getName(), e); + } + } + return result; + } + + @Nullable + private static List collectOwnColumnIndex( + @NotNull DBRProgressMonitor monitor, + @NotNull DBSEntityAssociation association, + boolean reverse, + @NotNull Map attrToIndex + ) throws DBException { + DBSEntityReferrer ownSide; + if (reverse) { + DBSEntityConstraint refConstraint = association.getReferencedConstraint(); + if (!(refConstraint instanceof DBSEntityReferrer referrer)) { + return null; + } + ownSide = referrer; + } else { + if (!(association instanceof DBSEntityReferrer associationRef)) { + return null; + } + ownSide = associationRef; + } + List attrs = ownSide.getAttributeReferences(monitor); + if (attrs == null || attrs.isEmpty()) { + return null; + } + List indexList = new ArrayList<>(attrs.size()); + for (DBSEntityAttributeRef attrRef : attrs) { + Integer idx = attrToIndex.get(attrRef.getAttribute()); + if (idx == null) { + return null; + } + indexList.add(idx); + } + return indexList; + } } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java index bde7777ba27..abc3ed90798 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java @@ -232,6 +232,17 @@ public void bindWiring(DBWBindingContext model) throws DBWebException { getDataFilter(env), getDataFormat(env) )) + .dataFetcher("asyncSqlNavigateForeignKey", env -> + getService(env).asyncNavigateForeignKey( + getWebSession(env), + getSQLContext(env), + getArgumentVal(env, "resultsId"), + new WebSQLResultsRow(getArgument(env, "row")), + getArgumentVal(env, "columnIndex"), + getArgumentVal(env, "associationName"), + CommonUtils.toBoolean(env.getArgument("isReference")), + getDataFormat(env) + )) .dataFetcher("asyncSqlExecuteResults", env -> getService(env).asyncGetQueryResults( getWebSession(env), getArgumentVal(env, "taskId") diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java index 5ff65671c61..38809ff548e 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java @@ -36,6 +36,8 @@ import org.jkiss.dbeaver.model.DBPDataSourceContainer; import org.jkiss.dbeaver.model.DBUtils; import org.jkiss.dbeaver.model.data.DBDAttributeBinding; +import org.jkiss.dbeaver.model.data.DBDReferenceNavigation; +import org.jkiss.dbeaver.model.data.DBDReferenceUtils; import org.jkiss.dbeaver.model.exec.DBCException; import org.jkiss.dbeaver.model.exec.DBCLogicalOperator; import org.jkiss.dbeaver.model.exec.DBExecUtils; @@ -59,9 +61,7 @@ import org.jkiss.dbeaver.model.sql.semantics.completion.SQLCompletionProposalComparator; import org.jkiss.dbeaver.model.sql.semantics.completion.SQLQueryCompletionAnalyzer; import org.jkiss.dbeaver.model.sql.semantics.completion.SQLQueryCompletionContext; -import org.jkiss.dbeaver.model.struct.DBSDataContainer; -import org.jkiss.dbeaver.model.struct.DBSObject; -import org.jkiss.dbeaver.model.struct.DBSWrapper; +import org.jkiss.dbeaver.model.struct.*; import org.jkiss.dbeaver.runtime.DBWorkbench; import org.jkiss.dbeaver.utils.RuntimeUtils; import org.jkiss.utils.CommonUtils; @@ -603,6 +603,74 @@ public void run(DBRProgressMonitor monitor) throws InvocationTargetException { return contextInfo.getProcessor().getWebSession().createAndRunAsyncTask("Read data from container " + nodePath, runnable); } + @Override + public WebAsyncTaskInfo asyncNavigateForeignKey( + @NotNull WebSession webSession, + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull WebSQLResultsRow row, + int columnIndex, + @NotNull String associationName, + boolean isReference, + @Nullable WebDataFormat dataFormat + ) { + WebAsyncTaskProcessor runnable = new WebAsyncTaskProcessor<>() { + @Override + public void run(DBRProgressMonitor monitor) throws InvocationTargetException { + try { + monitor.beginTask("Navigate foreign key", 1); + WebSQLResultsInfo resultsInfo = contextInfo.getResults(resultsId); + DBSEntity sourceEntity = resolveSourceEntity(resultsInfo.getAttributes(), columnIndex); + DBSEntityAssociation association = isReference + ? DBExecUtils.findReverseAssociationByName(monitor, sourceEntity, associationName) + : DBExecUtils.findForwardAssociationByName(monitor, sourceEntity, associationName); + + WebDBDResultSetDataProvider dataProvider = new WebDBDResultSetDataProvider( + resultsId, + contextInfo, + List.of(row) + ); + DBDReferenceNavigation navigation = isReference + ? DBDReferenceUtils.resolveReferenceNavigation(monitor, dataProvider, association, dataProvider.getSelectedRows()) + : DBDReferenceUtils.resolveAssociationNavigation(monitor, dataProvider, association, dataProvider.getSelectedRows()); + if (!(navigation.getTargetEntity() instanceof DBSDataContainer targetDataContainer)) { + throw new DBWebException("Referenced entity '" + navigation.getTargetEntity().getName() + "' is not a data container"); + } + WebSQLExecuteInfo executeResults = contextInfo.getProcessor().readDataFromContainer( + contextInfo, + monitor, + targetDataContainer, + null, + WebSQLDataFilter.from(navigation.getTargetFilter()), + dataFormat + ); + this.result = executeResults.getStatusMessage(); + this.extendedResults = executeResults; + } catch (Throwable e) { + throw new InvocationTargetException(e); + } finally { + monitor.done(); + } + } + }; + return webSession.createAndRunAsyncTask("Navigate foreign key from results " + resultsId, runnable); + } + + @NotNull + private DBSEntity resolveSourceEntity( + @NotNull DBDAttributeBinding[] attributes, + int columnIndex + ) throws DBException { + if (columnIndex < 0 || columnIndex >= attributes.length) { + throw new DBWebException("Column index '" + columnIndex + "' is out of range"); + } + DBSEntityAttribute entityAttribute = attributes[columnIndex].getEntityAttribute(); + if (entityAttribute == null) { + throw new DBException("Column [" + attributes[columnIndex].getName() + "] is not bound to any entity"); + } + return entityAttribute.getParentObject(); + } + @NotNull @Override public List readDynamicTrace(