diff --git a/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls b/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls index 137cadbdf2..e4db4689e6 100644 --- a/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls @@ -331,7 +331,8 @@ extend type Mutation { addedRows: [ SQLResultRow! ], ): String! - #Return BLOB name + # Returns BLOB name + @deprecated(reason: "23.3.3") # use sqlReadLobValue readLobValue( projectId: ID, connectionId: ID!, @@ -341,6 +342,27 @@ extend type Mutation { row: [ SQLResultRow! ]! ): String! + @since(version: "23.3.3") + sqlReadLobValue( + projectId: ID, + connectionId: ID!, + contextId: ID!, + resultsId: ID!, + lobColumnIndex: Int!, + row: SQLResultRow! + ): String! + + # Returns full string value ignoring any limits + @since(version: "23.3.3") + sqlReadStringValue( + projectId: ID, + connectionId: ID!, + contextId: ID!, + resultsId: ID!, + columnIndex: Int!, + row: SQLResultRow! + ): String! + # Returns SQLExecuteInfo asyncSqlExecuteResults(taskId: ID!): SQLExecuteInfo ! 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 37c72fe2d2..3dcb4b5f64 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 @@ -119,10 +119,18 @@ public interface DBWServiceSQL extends DBWService { @WebAction String readLobValue( - @NotNull WebSQLContextInfo contextInfo, - @NotNull String resultsId, - @NotNull Integer lobColumnIndex, - @Nullable List row) throws DBWebException; + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull Integer lobColumnIndex, + @NotNull WebSQLResultsRow row) throws DBWebException; + + @NotNull + @WebAction + String getCellValue( + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull Integer lobColumnIndex, + @NotNull WebSQLResultsRow row) throws DBWebException; @WebAction String updateResultsDataBatchScript( diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLCellValueReceiver.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLCellValueReceiver.java new file mode 100644 index 0000000000..8fafb7a38b --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLCellValueReceiver.java @@ -0,0 +1,85 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 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 org.jkiss.code.NotNull; +import org.jkiss.dbeaver.model.data.DBDAttributeBindingMeta; +import org.jkiss.dbeaver.model.data.DBDContent; +import org.jkiss.dbeaver.model.data.DBDDataReceiver; +import org.jkiss.dbeaver.model.data.DBDValue; +import org.jkiss.dbeaver.model.exec.*; +import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; +import org.jkiss.dbeaver.model.struct.DBSDataContainer; +import org.jkiss.dbeaver.utils.ContentUtils; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +public class WebSQLCellValueReceiver implements DBDDataReceiver { + protected final DBSDataContainer dataContainer; + protected int rowIndex; + protected DBDAttributeBindingMeta binding; + protected Object value; + + public WebSQLCellValueReceiver(DBSDataContainer dataContainer, int rowIndex) { + this.dataContainer = dataContainer; + this.rowIndex = rowIndex; + } + + @Override + public void fetchStart(DBCSession session, DBCResultSet resultSet, long offset, long maxRows) throws DBCException { + DBCResultSetMetaData meta = resultSet.getMeta(); + List attributes = meta.getAttributes(); + DBCAttributeMetaData attrMeta = attributes.get(rowIndex); + binding = new DBDAttributeBindingMeta(dataContainer, resultSet.getSession(), attrMeta); + } + + @Override + public void fetchRow(DBCSession session, DBCResultSet resultSet) throws DBCException { + value = binding.getValueHandler().fetchValueObject( + resultSet.getSession(), + resultSet, + binding.getMetaAttribute(), + rowIndex); + } + + @Override + public void fetchEnd(DBCSession session, DBCResultSet resultSet) throws DBCException { + + } + + @Override + public void close() { + + } + + @NotNull + public byte[] getBinaryValue(DBRProgressMonitor monitor) throws DBCException { + byte[] binaryValue; + if (value instanceof DBDContent dbdContent) { + binaryValue = ContentUtils.getContentBinaryValue(monitor, dbdContent); + } else if (value instanceof DBDValue dbdValue) { + binaryValue = dbdValue.getRawValue().toString().getBytes(); + } else { + binaryValue = value.toString().getBytes(StandardCharsets.UTF_8); + } + if (binaryValue == null) { + throw new DBCException("Lob value is null"); + } + return binaryValue; + } +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataLOBReceiver.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataLOBReceiver.java index f08bb05147..fbfced3597 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataLOBReceiver.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataLOBReceiver.java @@ -20,39 +20,29 @@ import io.cloudbeaver.server.CBApplication; import io.cloudbeaver.server.CBConstants; import io.cloudbeaver.server.CBPlatform; import org.jkiss.dbeaver.Log; -import org.jkiss.dbeaver.model.data.*; import org.jkiss.dbeaver.model.exec.*; +import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; import org.jkiss.dbeaver.model.runtime.VoidProgressMonitor; import org.jkiss.dbeaver.model.sql.DBQuotaException; import org.jkiss.dbeaver.model.struct.DBSDataContainer; -import org.jkiss.dbeaver.utils.ContentUtils; import org.jkiss.utils.CommonUtils; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.sql.Timestamp; import java.text.SimpleDateFormat; -import java.util.List; -public class WebSQLDataLOBReceiver implements DBDDataReceiver { +public class WebSQLDataLOBReceiver extends WebSQLCellValueReceiver { private static final Log log = Log.getLog(WebSQLDataLOBReceiver.class); public static final Path DATA_EXPORT_FOLDER = CBPlatform.getInstance().getTempFolder(new VoidProgressMonitor(), "sql-lob-files"); - private final String tableName; - private final DBSDataContainer dataContainer; - - private DBDAttributeBinding binding; - private Object lobValue; - private int rowIndex; WebSQLDataLOBReceiver(String tableName, DBSDataContainer dataContainer, int rowIndex) { + super(dataContainer, rowIndex); this.tableName = tableName; - this.dataContainer = dataContainer; - this.rowIndex = rowIndex; - if (!Files.exists(DATA_EXPORT_FOLDER)){ + if (!Files.exists(DATA_EXPORT_FOLDER)) { try { Files.createDirectories(DATA_EXPORT_FOLDER); } catch (IOException e) { @@ -62,64 +52,28 @@ public class WebSQLDataLOBReceiver implements DBDDataReceiver { } - public String createLobFile(DBCSession session) throws DBCException, IOException { + public String createLobFile(DBRProgressMonitor monitor) throws DBCException, IOException { String exportFileName = CommonUtils.truncateString(tableName, 32); StringBuilder fileName = new StringBuilder(exportFileName); fileName.append("_") - .append(binding.getName()) - .append("_"); + .append(binding.getName()) + .append("_"); Timestamp ts = new Timestamp(System.currentTimeMillis()); String s = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss").format(ts); fileName.append(s); exportFileName = CommonUtils.escapeFileName(fileName.toString()); - byte[] binaryValue; + byte[] binaryValue = getBinaryValue(monitor); Number fileSizeLimit = CBApplication.getInstance().getAppConfiguration().getResourceQuota(CBConstants.QUOTA_PROP_FILE_LIMIT); - if (lobValue instanceof DBDContent) { - binaryValue = ContentUtils.getContentBinaryValue(session.getProgressMonitor(), (DBDContent) lobValue); - } else if (lobValue instanceof DBDValue) { - binaryValue = ((DBDValue) lobValue).getRawValue().toString().getBytes(); - } else { - binaryValue = lobValue.toString().getBytes(StandardCharsets.UTF_8); - } - if (binaryValue == null) { - throw new DBCException("Lob value is null"); - } if (binaryValue.length > fileSizeLimit.longValue()) { throw new DBQuotaException( - "Data export quota exceeded \n Please increase the resourceQuotas parameter in configuration", + "Data export quota exceeded \n Please increase the resourceQuotas parameter in configuration", CBConstants.QUOTA_PROP_FILE_LIMIT, fileSizeLimit.longValue(), binaryValue.length ); } - Path file = DATA_EXPORT_FOLDER.resolve(exportFileName); + Path file = WebSQLDataLOBReceiver.DATA_EXPORT_FOLDER.resolve(exportFileName); Files.write(file, binaryValue); return exportFileName; } - - @Override - public void fetchStart(DBCSession session, DBCResultSet resultSet, long offset, long maxRows) throws DBCException { - DBCResultSetMetaData meta = resultSet.getMeta(); - List attributes = meta.getAttributes(); - DBCAttributeMetaData attrMeta = attributes.get(rowIndex); - binding = new DBDAttributeBindingMeta(dataContainer, resultSet.getSession(), attrMeta); - } - @Override - public void fetchRow(DBCSession session, DBCResultSet resultSet) throws DBCException { - lobValue = binding.getValueHandler().fetchValueObject( - resultSet.getSession(), - resultSet, - binding.getMetaAttribute(), - rowIndex); - } - - @Override - public void fetchEnd(DBCSession session, DBCResultSet resultSet) throws DBCException { - - } - - @Override - public void close() { - - } } 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 65750fc58c..be9715377c 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 @@ -54,6 +54,7 @@ import org.jkiss.utils.CommonUtils; import java.io.IOException; import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; @@ -797,14 +798,27 @@ public class WebSQLProcessor implements WebSessionProvider { DBDRowIdentifier rowIdentifier = resultsInfo.getDefaultRowIdentifier(); checkRowIdentifier(resultsInfo, rowIdentifier); + String tableName = rowIdentifier.getEntity().getName(); + WebSQLDataLOBReceiver dataReceiver = new WebSQLDataLOBReceiver(tableName, resultsInfo.getDataContainer(), lobColumnIndex); + readCellDataValue(monitor, resultsInfo, row, dataReceiver); + try { + return dataReceiver.createLobFile(monitor); + } catch (Exception e) { + throw new DBWebException("Error creating temporary lob file ", e); + } + } + + private void readCellDataValue( + @NotNull DBRProgressMonitor monitor, + @NotNull WebSQLResultsInfo resultsInfo, + @Nullable WebSQLResultsRow row, + @NotNull WebSQLCellValueReceiver dataReceiver) throws DBException { DBSDataContainer dataContainer = resultsInfo.getDataContainer(); DBCExecutionContext executionContext = getExecutionContext(dataContainer); - String tableName = rowIdentifier.getEntity().getName(); - WebSQLDataLOBReceiver dataReceiver = new WebSQLDataLOBReceiver(tableName, dataContainer, lobColumnIndex); try (DBCSession session = executionContext.openSession(monitor, DBCExecutionPurpose.USER, "Generate data update batches")) { WebExecutionSource executionSource = new WebExecutionSource(dataContainer, executionContext, this); DBDDataFilter dataFilter = new DBDDataFilter(); - DBDAttributeBinding[] keyAttributes = rowIdentifier.getAttributes().toArray(new DBDAttributeBinding[0]); + DBDAttributeBinding[] keyAttributes = resultsInfo.getDefaultRowIdentifier().getAttributes().toArray(new DBDAttributeBinding[0]); Object[] rowValues = new Object[keyAttributes.length]; List constraints = new ArrayList<>(); for (int i = 0; i < keyAttributes.length; i++) { @@ -833,14 +847,25 @@ public class WebSQLProcessor implements WebSessionProvider { DBCStatistics statistics = dataContainer.readData( executionSource, session, dataReceiver, dataFilter, 0, 1, DBSDataContainer.FLAG_NONE, 1); - try { - return dataReceiver.createLobFile(session); - } catch (Exception e) { - throw new DBWebException("Error creating temporary lob file ", e); - } } } + @NotNull + public String readStringValue( + @NotNull DBRProgressMonitor monitor, + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull Integer columnIndex, + @Nullable WebSQLResultsRow row + ) throws DBException { + WebSQLResultsInfo resultsInfo = contextInfo.getResults(resultsId); + DBDRowIdentifier rowIdentifier = resultsInfo.getDefaultRowIdentifier(); + checkRowIdentifier(resultsInfo, rowIdentifier); + WebSQLCellValueReceiver dataReceiver = new WebSQLCellValueReceiver(resultsInfo.getDataContainer(), columnIndex); + readCellDataValue(monitor, resultsInfo, row, dataReceiver); + return new String(dataReceiver.getBinaryValue(monitor), StandardCharsets.UTF_8); + } + //////////////////////////////////////////////// // Misc 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 e24cf433b6..cc266b8265 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 @@ -131,12 +131,24 @@ public class WebServiceBindingSQL extends WebServiceBindingBase i getSQLContext(env), env.getArgument("resultId")); }) - .dataFetcher("readLobValue", env -> - getService(env).readLobValue( - getSQLContext(env), - env.getArgument("resultsId"), - env.getArgument("lobColumnIndex"), - getResultsRow(env, "row"))) + .dataFetcher("readLobValue", env -> // deprecated + getService(env).readLobValue( + getSQLContext(env), + env.getArgument("resultsId"), + env.getArgument("lobColumnIndex"), + getResultsRow(env, "row").get(0))) + .dataFetcher("sqlReadLobValue", env -> + getService(env).readLobValue( + getSQLContext(env), + env.getArgument("resultsId"), + env.getArgument("lobColumnIndex"), + new WebSQLResultsRow(env.getArgument("row")))) + .dataFetcher("sqlReadStringValue", env -> + getService(env).getCellValue( + getSQLContext(env), + env.getArgument("resultsId"), + env.getArgument("columnIndex"), + new WebSQLResultsRow(env.getArgument("row")))) .dataFetcher("updateResultsDataBatch", env -> getService(env).updateResultsDataBatch( getSQLContext(env), 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 1302be8e7d..dc4aef0309 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 @@ -60,6 +60,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -319,31 +322,62 @@ public class WebServiceSQL implements DBWServiceSQL { } } + @FunctionalInterface + private interface ThrowableFunction { + R apply(T obj) throws Exception; + } + @Override public String readLobValue( @NotNull WebSQLContextInfo contextInfo, @NotNull String resultsId, @NotNull Integer lobColumnIndex, - @Nullable List row) throws DBWebException + @NotNull WebSQLResultsRow row) throws DBWebException { + ThrowableFunction function = monitor -> contextInfo.getProcessor().readLobValue( + monitor, contextInfo, resultsId, lobColumnIndex, row); + return readValue(function, contextInfo.getProcessor()); + } + + @NotNull + @Override + public String getCellValue( + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull Integer lobColumnIndex, + @NotNull WebSQLResultsRow row + ) throws DBWebException { + if (row == null) { + throw new DBWebException("Results row is not found"); + } + WebSQLProcessor processor = contextInfo.getProcessor(); + ThrowableFunction function = monitor -> processor.readStringValue( + monitor, contextInfo, resultsId, lobColumnIndex, row); + return readValue(function, processor); + } + + @NotNull + private String readValue( + @NotNull ThrowableFunction function, + @NotNull WebSQLProcessor processor + ) throws DBWebException { try { var result = new StringBuilder(); DBExecUtils.tryExecuteRecover( - contextInfo.getProcessor().getWebSession().getProgressMonitor(), - contextInfo.getProcessor().getConnection().getDataSource(), - monitor -> { - try { - result.append(contextInfo.getProcessor().readLobValue( - monitor, contextInfo, resultsId, lobColumnIndex, row.get(0))); - } catch (Exception e) { - throw new InvocationTargetException(e); - } + processor.getWebSession().getProgressMonitor(), + processor.getConnection().getDataSource(), + monitor -> { + try { + result.append(function.apply(monitor)); + } catch (Exception e) { + throw new InvocationTargetException(e); } + } ); return result.toString(); } catch (DBException e) { - throw new DBWebException("Error reading LOB value ", e); + throw new DBWebException("Error reading value ", e); } } diff --git a/webapp/packages/core-sdk/src/queries/grid/getResultsetDataURL.gql b/webapp/packages/core-sdk/src/queries/grid/getResultsetDataURL.gql index 91aa5c3ca2..6eec6ae8e7 100644 --- a/webapp/packages/core-sdk/src/queries/grid/getResultsetDataURL.gql +++ b/webapp/packages/core-sdk/src/queries/grid/getResultsetDataURL.gql @@ -1,9 +1,3 @@ -mutation getResultsetDataURL($connectionId: ID!, $contextId: ID!, $resultsId: ID!, $lobColumnIndex: Int!, $row: [ SQLResultRow! ]!) { - url: readLobValue( - connectionId: $connectionId, - contextId: $contextId, - resultsId: $resultsId, - lobColumnIndex: $lobColumnIndex, - row: $row - ) -} \ No newline at end of file +mutation getResultsetDataURL($connectionId: ID!, $contextId: ID!, $resultsId: ID!, $lobColumnIndex: Int!, $row: SQLResultRow!) { + url: sqlReadLobValue(connectionId: $connectionId, contextId: $contextId, resultsId: $resultsId, lobColumnIndex: $lobColumnIndex, row: $row) +} diff --git a/webapp/packages/core-sdk/src/queries/sqlReadStringValue.gql b/webapp/packages/core-sdk/src/queries/sqlReadStringValue.gql new file mode 100644 index 0000000000..ce9b225e37 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/sqlReadStringValue.gql @@ -0,0 +1,3 @@ +mutation sqlReadStringValue($connectionId: ID!, $contextId: ID!, $resultsId: ID!, $columnIndex: Int!, $row: SQLResultRow!) { + text: sqlReadStringValue(connectionId: $connectionId, contextId: $contextId, resultsId: $resultsId, columnIndex: $columnIndex, row: $row) +} diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts index aa7b3900bc..3d254d940e 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts @@ -26,11 +26,16 @@ import { ResultSetViewAction } from './ResultSetViewAction'; const RESULT_VALUE_PATH = 'sql-result-value'; +interface ICacheEntry { + url?: string; + fullText?: string; +} + @databaseDataAction() export class ResultSetDataContentAction extends DatabaseDataAction implements IResultSetDataContentAction { static dataFormat = [ResultDataFormat.Resultset]; - private readonly cache: Map; + private readonly cache: Map>; activeElement: IResultSetElementKey | null; constructor( @@ -60,6 +65,52 @@ export class ResultSetDataContentAction extends DatabaseDataAction { + try { + this.activeElement = element; + return await this.loadFileFullText(this.result, column.position, row); + } finally { + this.activeElement = null; + } + }); + + this.updateCache(element, { fullText }); + + return fullText; + } + async getFileDataUrl(element: IResultSetElementKey) { const column = this.data.getColumn(element.column); const row = this.data.getRowValue(element.row); @@ -82,21 +133,32 @@ export class ResultSetDataContentAction extends DatabaseDataAction) { + const hash = this.getHash(element); + const cachedElement = this.cache.get(hash) ?? {}; + this.cache.set(hash, { ...cachedElement, ...partialCache }); + } + + retrieveFileFullTextFromCache(element: IResultSetElementKey) { + const hash = this.getHash(element); + return this.cache.get(hash)?.fullText; + } + retrieveFileDataUrlFromCache(element: IResultSetElementKey) { const hash = this.getHash(element); - return this.cache.get(hash); + return this.cache.get(hash)?.url; } async downloadFileData(element: IResultSetElementKey) { diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/useResultActions.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/useResultActions.ts new file mode 100644 index 0000000000..0126441793 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/useResultActions.ts @@ -0,0 +1,58 @@ +import { computed, observable } from 'mobx'; + +import { useObservableRef } from '@cloudbeaver/core-blocks'; + +import type { IDatabaseDataModel } from '../../IDatabaseDataModel'; +import type { IDatabaseResultSet } from '../../IDatabaseResultSet'; +import { ResultSetConstraintAction } from './ResultSetConstraintAction'; +import { ResultSetDataAction } from './ResultSetDataAction'; +import { ResultSetDataContentAction } from './ResultSetDataContentAction'; +import { ResultSetEditAction } from './ResultSetEditAction'; +import { ResultSetFormatAction } from './ResultSetFormatAction'; +import { ResultSetSelectAction } from './ResultSetSelectAction'; +import { ResultSetViewAction } from './ResultSetViewAction'; + +interface IResultActionsArgs { + resultIndex: number; + model: IDatabaseDataModel; +} + +export function useResultActions({ model, resultIndex }: IResultActionsArgs) { + return useObservableRef( + () => ({ + get dataAction(): ResultSetDataAction { + return this.model.source.getAction(this.resultIndex, ResultSetDataAction); + }, + get selectAction(): ResultSetSelectAction { + return this.model.source.getAction(this.resultIndex, ResultSetSelectAction); + }, + get editAction(): ResultSetEditAction { + return this.model.source.getAction(this.resultIndex, ResultSetEditAction); + }, + get contentAction(): ResultSetDataContentAction { + return this.model.source.getAction(this.resultIndex, ResultSetDataContentAction); + }, + get formatAction(): ResultSetFormatAction { + return this.model.source.getAction(this.resultIndex, ResultSetFormatAction); + }, + get constraintAction(): ResultSetConstraintAction { + return this.model.source.getAction(this.resultIndex, ResultSetConstraintAction); + }, + get viewAction(): ResultSetViewAction { + return this.model.source.getAction(this.resultIndex, ResultSetViewAction); + }, + }), + { + dataAction: computed, + selectAction: computed, + editAction: computed, + contentAction: computed, + formatAction: computed, + constraintAction: computed, + viewAction: computed, + model: observable.ref, + resultIndex: observable.ref, + }, + { model, resultIndex }, + ); +} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.m.css b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.m.css new file mode 100644 index 0000000000..e909fb879a --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.m.css @@ -0,0 +1,6 @@ +.toolsContainer { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx index c09c2daa82..6e96115529 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx @@ -10,25 +10,23 @@ import { observer } from 'mobx-react-lite'; import { useMemo } from 'react'; import styled, { css } from 'reshadow'; -import { Button, useStyles, useTranslate } from '@cloudbeaver/core-blocks'; +import { Button, s, useS, useStyles, useTranslate } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { QuotasService } from '@cloudbeaver/core-root'; import { BASE_TAB_STYLES, TabContainerPanelComponent, TabList, TabsState, UNDERLINE_TAB_STYLES, useTabLocalState } from '@cloudbeaver/core-ui'; -import { bytesToSize } from '@cloudbeaver/core-utils'; +import { bytesToSize, isNotNullDefined } from '@cloudbeaver/core-utils'; import { EditorLoader, useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6'; -import type { IResultSetElementKey } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetDataKey'; import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; -import { ResultSetDataContentAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction'; -import { ResultSetEditAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetEditAction'; -import { ResultSetFormatAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction'; import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; +import { useResultActions } from '../../DatabaseDataModel/Actions/ResultSet/useResultActions'; import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService'; import { QuotaPlaceholder } from '../QuotaPlaceholder'; import { VALUE_PANEL_TOOLS_STYLES } from '../ValuePanelTools/VALUE_PANEL_TOOLS_STYLES'; import { getTypeExtension } from './getTypeExtension'; +import moduleStyles from './TextValuePresentation.m.css'; import { TextValuePresentationService } from './TextValuePresentationService'; import { useTextValue } from './useTextValue'; @@ -70,6 +68,10 @@ const styles = css` } `; +const TEXT_PLAIN_TYPE = 'text/plain'; +const TEXT_JSON_TYPE = 'text/json'; +const APPLICATION_JSON_TYPE = 'application/json'; + export const TextValuePresentation: TabContainerPanelComponent> = observer( function TextValuePresentation({ model, resultIndex, dataFormat }) { const translate = useTranslate(); @@ -77,75 +79,58 @@ export const TextValuePresentation: TabContainerPanelComponent observable({ - currentContentType: 'text/plain', - lastContentType: 'text/plain', + currentContentType: TEXT_PLAIN_TYPE, - setContentType(type: string) { - this.currentContentType = type; + setContentType(contentType: string) { + if (contentType === TEXT_JSON_TYPE) { + contentType = APPLICATION_JSON_TYPE; + } + + this.currentContentType = contentType; }, - setDefaultContentType(type: string) { - this.currentContentType = type; - this.lastContentType = type; + handleTabOpen(tabId: string) { + // currentContentType may be selected automatically we don't want to change state in this case + if (tabId !== this.currentContentType) { + this.setContentType(tabId); + } }, }), ); - - const selection = model.source.getAction(resultIndex, ResultSetSelectAction); - const editor = model.source.getAction(resultIndex, ResultSetEditAction); - const content = model.source.getAction(resultIndex, ResultSetDataContentAction); - - const activeElements = selection.getActiveElements(); - const activeTabs = textValuePresentationService.tabs.getDisplayed({ dataFormat, model, resultIndex }); - - let contentType = 'text/plain'; - let firstSelectedCell: IResultSetElementKey | undefined; - let readonly = true; - let valueTruncated = false; - let limit: string | undefined; - let valueSize: string | undefined; - - if (activeElements.length > 0) { - const format = model.source.getAction(resultIndex, ResultSetFormatAction); - - firstSelectedCell = activeElements[0]; - - const value = format.get(firstSelectedCell); - readonly = format.isReadOnly(firstSelectedCell) || format.isBinary(firstSelectedCell); - - if (isResultSetContentValue(value)) { - valueTruncated = content.isContentTruncated(value); - - if (valueTruncated) { - limit = bytesToSize(quotasService.getQuota('sqlBinaryPreviewMaxLength')); - valueSize = bytesToSize(value.contentLength ?? 0); - } - - if (value.contentType) { - contentType = value.contentType; - - if (contentType === 'text/json') { - contentType = 'application/json'; - } - - if (!activeTabs.some(tab => tab.key === contentType)) { - contentType = 'text/plain'; - } - } - } - } - - readonly = model.isReadonly(resultIndex) || model.isDisabled(resultIndex) || readonly; - - if (contentType !== state.lastContentType) { - state.setDefaultContentType(contentType); - } + const { textValue, isTruncated, isTextColumn, pasteFullText } = useTextValue({ + model, + resultIndex, + currentContentType: state.currentContentType, + }); + const isSelectedCellReadonly = firstSelectedCell && (formatAction.isReadOnly(firstSelectedCell) || formatAction.isBinary(firstSelectedCell)); + const isReadonlyByResultIndex = model.isReadonly(resultIndex) || model.isDisabled(resultIndex) || !firstSelectedCell; + const isReadonly = isSelectedCellReadonly || isReadonlyByResultIndex; + const valueSize = + isResultSetContentValue(contentValue) && isNotNullDefined(contentValue.contentLength) ? bytesToSize(contentValue.contentLength) : undefined; + const limit = bytesToSize(quotasService.getQuota('sqlBinaryPreviewMaxLength')); + const canSave = firstSelectedCell && contentAction.isDownloadable(firstSelectedCell); + const shouldShowPasteButton = isTextColumn && isTruncated; + const typeExtension = useMemo(() => getTypeExtension(state.currentContentType) ?? [], [state.currentContentType]); + const extensions = useCodemirrorExtensions(undefined, typeExtension); function handleChange(newValue: string) { - if (firstSelectedCell && !readonly) { - editor.set(firstSelectedCell, newValue); + if (firstSelectedCell && !isReadonly) { + editAction.set(firstSelectedCell, newValue); } } @@ -155,33 +140,15 @@ export const TextValuePresentation: TabContainerPanelComponent 0 && !activeTabs.some(tab => tab.key === currentContentType)) { - currentContentType = activeTabs[0].key; - } - - const canSave = !!firstSelectedCell && content.isDownloadable(firstSelectedCell); - const typeExtension = useMemo(() => getTypeExtension(currentContentType) ?? [], [currentContentType]); - const extensions = useCodemirrorExtensions(undefined, typeExtension); - - const value = useTextValue({ - model, - resultIndex, - currentContentType, - }); - - function handleTabOpen(tabId: string) { - // currentContentType may be selected automatically we don't want to change state in this case - if (tabId !== currentContentType) { - state.setContentType(tabId); - } + if (!activeTabs.some(tab => tab.key === state.currentContentType)) { + const contentType = activeTabs.length > 0 && activeTabs[0].key ? activeTabs[0].key : TEXT_PLAIN_TYPE; + state.setContentType(contentType); } return styled(style)( @@ -191,23 +158,28 @@ export const TextValuePresentation: TabContainerPanelComponent handleTabOpen(tab.tabId)} + onChange={tab => state.handleTabOpen(tab.tabId)} > - handleChange(value)} /> - {valueTruncated && } - {canSave && ( - + + {isTruncated && } +
+ {canSave && ( - - )} + )} + {shouldShowPasteButton && ( + + )} +
, ); }, diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts index 47997dff6d..001a8cf581 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts @@ -5,12 +5,14 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ +import { useService } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; import { isNotNullDefined } from '@cloudbeaver/core-utils'; import { isResultSetBinaryFileValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBinaryFileValue'; -import { ResultSetEditAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetEditAction'; -import { ResultSetFormatAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction'; +import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; +import { useResultActions } from '../../DatabaseDataModel/Actions/ResultSet/useResultActions'; import type { IDatabaseDataModel } from '../../DatabaseDataModel/IDatabaseDataModel'; import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; import { useAutoFormat } from './useAutoFormat'; @@ -21,31 +23,71 @@ interface IUseTextValueArgs { currentContentType: string; } -export function useTextValue({ model, resultIndex, currentContentType }: IUseTextValueArgs) { - const format = model.source.getAction(resultIndex, ResultSetFormatAction); - const editor = model.source.getAction(resultIndex, ResultSetEditAction); +interface IUseTextValue { + textValue: string; + isTruncated: boolean; + isTextColumn: boolean; + pasteFullText(): Promise; +} + +export function useTextValue({ model, resultIndex, currentContentType }: IUseTextValueArgs): IUseTextValue { + const { formatAction, editAction, contentAction, dataAction } = useResultActions({ model, resultIndex }); const selection = model.source.getAction(resultIndex, ResultSetSelectAction); const activeElements = selection.getActiveElements(); const firstSelectedCell = activeElements?.[0]; const formatter = useAutoFormat(); + const columnType = firstSelectedCell ? dataAction.getColumn(firstSelectedCell.column)?.dataKind : ''; + const isTextColumn = columnType?.toLocaleLowerCase() === 'string'; + const contentValue = firstSelectedCell ? formatAction.get(firstSelectedCell) : null; + const cachedFullText = firstSelectedCell ? contentAction.retrieveFileFullTextFromCache(firstSelectedCell) : ''; + const blob = firstSelectedCell ? formatAction.get(firstSelectedCell) : null; + const notificationService = useService(NotificationService); + + const result: IUseTextValue = { + textValue: '', + isTruncated: false, + isTextColumn, + async pasteFullText() { + if (!firstSelectedCell) { + return; + } + + try { + await contentAction.getFileFullText(firstSelectedCell); + } catch (exception) { + notificationService.logException(exception as any, 'data_viewer_presentation_value_content_paste_error'); + } + }, + }; if (!isNotNullDefined(firstSelectedCell)) { - return ''; + return result; } - if (editor.isElementEdited(firstSelectedCell)) { - return format.getText(firstSelectedCell); + if (isResultSetContentValue(contentValue)) { + result.isTruncated = contentAction.isContentTruncated(contentValue); } - const blob = format.get(firstSelectedCell); + if (isTextColumn && cachedFullText) { + result.textValue = cachedFullText; + result.isTruncated = false; + } + + if (editAction.isElementEdited(firstSelectedCell)) { + result.textValue = formatAction.getText(firstSelectedCell); + } if (isResultSetBinaryFileValue(blob)) { const value = formatter.formatBlob(currentContentType, blob); if (value) { - return value; + result.textValue = value; } } - return formatter.format(currentContentType, format.getText(firstSelectedCell)); + if (!result.textValue) { + result.textValue = formatter.format(currentContentType, formatAction.getText(firstSelectedCell)); + } + + return result; } diff --git a/webapp/packages/plugin-data-viewer/src/locales/en.ts b/webapp/packages/plugin-data-viewer/src/locales/en.ts index fec9e03994..a6aad623d4 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/en.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/en.ts @@ -38,6 +38,8 @@ export default [ ['data_viewer_presentation_value_content_was_truncated', 'The value was truncated'], ['data_viewer_presentation_value_content_value_size', 'Value size'], ['data_viewer_presentation_value_content_download_error', 'Download failed'], + ['data_viewer_presentation_value_content_paste_error', 'Cannot load full text'], + ['data_viewer_presentation_value_content_full_text_button', 'View full text'], ['data_viewer_script_preview', 'Script'], ['data_viewer_script_preview_dialog_title', 'Preview changes'], ['data_viewer_script_preview_error_title', "Can't get the script"], diff --git a/webapp/packages/plugin-data-viewer/src/locales/it.ts b/webapp/packages/plugin-data-viewer/src/locales/it.ts index c4ea762cc5..1e4176ad7e 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/it.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/it.ts @@ -34,6 +34,8 @@ export default [ ['data_viewer_presentation_value_content_was_truncated', 'The value was truncated'], ['data_viewer_presentation_value_content_value_size', 'Value size'], ['data_viewer_presentation_value_content_download_error', 'Download failed'], + ['data_viewer_presentation_value_content_paste_error', 'Cannot load full text'], + ['data_viewer_presentation_value_content_full_text_button', 'View full text'], ['data_viewer_refresh_result_set', 'Refresh result set'], ['data_viewer_total_count_tooltip', 'Get total count'], ['data_viewer_total_count_failed', 'Failed to get total count'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/ru.ts b/webapp/packages/plugin-data-viewer/src/locales/ru.ts index 74e1d0cd72..82624eea1a 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/ru.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/ru.ts @@ -32,6 +32,8 @@ export default [ ['data_viewer_presentation_value_content_was_truncated', 'Значение было обрезано'], ['data_viewer_presentation_value_content_value_size', 'Размер значения'], ['data_viewer_presentation_value_content_download_error', 'Не удалось загрузить файл'], + ['data_viewer_presentation_value_content_paste_error', 'Не удалось загрузить весь текст'], + ['data_viewer_presentation_value_content_full_text_button', 'Посмотреть весь текст'], ['data_viewer_script_preview', 'Скрипт'], ['data_viewer_script_preview_dialog_title', 'Предпросмотр изменений'], ['data_viewer_script_preview_error_title', 'Не удалось получить скрипт'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/zh.ts b/webapp/packages/plugin-data-viewer/src/locales/zh.ts index 2d03d261e8..9d4da4d6ac 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/zh.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/zh.ts @@ -38,6 +38,8 @@ export default [ ['data_viewer_presentation_value_content_was_truncated', 'The value was truncated'], ['data_viewer_presentation_value_content_value_size', 'Value size'], ['data_viewer_presentation_value_content_download_error', 'Download failed'], + ['data_viewer_presentation_value_content_paste_error', 'Cannot load full text'], + ['data_viewer_presentation_value_content_full_text_button', 'View full text'], ['data_viewer_script_preview', '脚本'], ['data_viewer_script_preview_dialog_title', '预览更改'], ['data_viewer_script_preview_error_title', '无法获取脚本'],