diff --git a/backend/src/entities/table/use-cases/add-row-in-table.use.case.ts b/backend/src/entities/table/use-cases/add-row-in-table.use.case.ts index f76090be7..60a9d30a9 100644 --- a/backend/src/entities/table/use-cases/add-row-in-table.use.case.ts +++ b/backend/src/entities/table/use-cases/add-row-in-table.use.case.ts @@ -5,35 +5,33 @@ import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/help import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; -import { - AmplitudeEventTypeEnum, - LogOperationTypeEnum, - OperationResultStatusEnum, -} from '../../../enums/index.js'; +import { AmplitudeEventTypeEnum, LogOperationTypeEnum, OperationResultStatusEnum } from '../../../enums/index.js'; import { TableActionEventEnum } from '../../../enums/table-action-event-enum.js'; import { Messages } from '../../../exceptions/text/messages.js'; import { isObjectEmpty, toPrettyErrorsMsg } from '../../../helpers/index.js'; import { AmplitudeService } from '../../amplitude/amplitude.service.js'; +import { CedarPermissionsService } from '../../cedar-authorization/cedar-permissions.service.js'; import { isTestConnectionUtil } from '../../connection/utils/is-test-connection-util.js'; import { TableActionActivationService } from '../../table-actions/table-actions-module/table-action-activation.service.js'; import { TableLogsService } from '../../table-logs/table-logs.service.js'; import { AddRowInTableDs } from '../application/data-structures/add-row-in-table.ds.js'; import { ReferencedTableNamesAndColumnsDs, TableRowRODs } from '../table-datastructures.js'; -import { convertBinaryDataInRowUtil } from '../utils/convert-binary-data-in-row.util.js'; +import { attachForeignColumnNames } from '../utils/attach-foreign-column-names.util.js'; +import { buildTableSettingsForResponse } from '../utils/build-table-settings-for-response.util.js'; import { convertHexDataInRowUtil } from '../utils/convert-hex-data-in-row.util.js'; +import { extractForeignKeysFromWidgets } from '../utils/extract-foreign-keys-from-widgets.util.js'; +import { filterForeignKeysByReadPermission } from '../utils/filter-foreign-keys-by-permission.util.js'; import { formFullTableStructure } from '../utils/form-full-table-structure.js'; import { hashPasswordsInRowUtil } from '../utils/hash-passwords-in-row.util.js'; +import { + enrichReferencedTablesWithDisplayNames, + filterReferencedTablesByPermission, +} from '../utils/process-referenced-tables.util.js'; import { processUuidsInRowUtil } from '../utils/process-uuids-in-row-util.js'; import { removePasswordsFromRowsUtil } from '../utils/remove-password-from-row.util.js'; +import { getUserEmailForAgent, validateConnection } from '../utils/validate-connection.util.js'; import { validateTableRowUtil } from '../utils/validate-table-row.util.js'; -import { CedarPermissionsService } from '../../cedar-authorization/cedar-permissions.service.js'; import { IAddRowInTable } from './table-use-cases.interface.js'; -import { validateConnection, getUserEmailForAgent } from '../utils/validate-connection.util.js'; -import { extractForeignKeysFromWidgets } from '../utils/extract-foreign-keys-from-widgets.util.js'; -import { filterForeignKeysByReadPermission } from '../utils/filter-foreign-keys-by-permission.util.js'; -import { attachForeignColumnNames } from '../utils/attach-foreign-column-names.util.js'; -import { filterReferencedTablesByPermission, enrichReferencedTablesWithDisplayNames } from '../utils/process-referenced-tables.util.js'; -import { buildTableSettingsForResponse } from '../utils/build-table-settings-for-response.util.js'; @Injectable() export class AddRowInTableUseCase extends AbstractUseCase implements IAddRowInTable { @@ -98,7 +96,13 @@ export class AddRowInTableUseCase extends AbstractUseCase = []; foreignKeysWithKeysFromWidgets = await filterForeignKeysByReadPermission( - foreignKeysWithKeysFromWidgets, userId, connectionId, masterPwd, this.cedarPermissions, + foreignKeysWithKeysFromWidgets, + userId, + connectionId, + masterPwd, + this.cedarPermissions, ); if (foreignKeysWithKeysFromWidgets?.length > 0) { foreignKeysWithAutocompleteColumns = await Promise.all( foreignKeysWithKeysFromWidgets.map((el) => attachForeignColumnNames( - el, userEmail, connectionId, dao, + el, + userEmail, + connectionId, + dao, this._dbContext.tableSettingsRepository.findTableSettings.bind(this._dbContext.tableSettingsRepository), ).catch(() => el as ForeignKeyWithAutocompleteColumnsDS), ), @@ -158,7 +169,6 @@ export class AddRowInTableUseCase extends AbstractUseCase = []; @@ -110,7 +116,10 @@ export class GetRowByPrimaryKeyUseCase foreignKeysWithAutocompleteColumns = await Promise.all( tableForeignKeys.map((el) => attachForeignColumnNames( - el, userEmail, connectionId, dao, + el, + userEmail, + connectionId, + dao, this._dbContext.tableSettingsRepository.findTableSettings.bind(this._dbContext.tableSettingsRepository), ).catch(() => el as ForeignKeyWithAutocompleteColumnsDS), ), @@ -133,10 +142,15 @@ export class GetRowByPrimaryKeyUseCase ); } rowData = removePasswordsFromRowsUtil(rowData, tableWidgets); - rowData = convertBinaryDataInRowUtil(rowData, tableStructure); const formedTableStructure = formFullTableStructure(tableStructure, tableSettings); - await filterReferencedTablesByPermission(referencedTableNamesAndColumns, userId, connectionId, masterPwd, this.cedarPermissions); + await filterReferencedTablesByPermission( + referencedTableNamesAndColumns, + userId, + connectionId, + masterPwd, + this.cedarPermissions, + ); const referencedTableNamesAndColumnsWithTablesDisplayNames = await enrichReferencedTablesWithDisplayNames( referencedTableNamesAndColumns, connectionId, diff --git a/backend/src/entities/table/use-cases/get-table-rows.use.case.ts b/backend/src/entities/table/use-cases/get-table-rows.use.case.ts index 7a74b725e..179292878 100644 --- a/backend/src/entities/table/use-cases/get-table-rows.use.case.ts +++ b/backend/src/entities/table/use-cases/get-table-rows.use.case.ts @@ -13,11 +13,7 @@ import Sentry from '@sentry/minimal'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; -import { - AmplitudeEventTypeEnum, - LogOperationTypeEnum, - OperationResultStatusEnum, -} from '../../../enums/index.js'; +import { AmplitudeEventTypeEnum, LogOperationTypeEnum, OperationResultStatusEnum } from '../../../enums/index.js'; import { ExceptionOperations } from '../../../exceptions/custom-exceptions/exception-operation.js'; import { UnknownSQLException } from '../../../exceptions/custom-exceptions/unknown-sql-exception.js'; import { Messages } from '../../../exceptions/text/messages.js'; @@ -25,6 +21,7 @@ import { hexToBinary, isBinary } from '../../../helpers/binary-to-hex.js'; import { Constants } from '../../../helpers/constants/constants.js'; import { isObjectEmpty } from '../../../helpers/index.js'; import { AmplitudeService } from '../../amplitude/amplitude.service.js'; +import { CedarPermissionsService } from '../../cedar-authorization/cedar-permissions.service.js'; import { buildActionEventDto } from '../../table-actions/table-action-rules-module/utils/build-found-action-event-dto.util.js'; import { buildCreatedTableFilterRO } from '../../table-filters/utils/build-created-table-filters-response-object.util.js'; import { TableLogsService } from '../../table-logs/table-logs.service.js'; @@ -33,6 +30,10 @@ import { PersonalTableSettingsEntity } from '../../table-settings/personal-table import { FoundTableRowsDs } from '../application/data-structures/found-table-rows.ds.js'; import { GetTableRowsDs } from '../application/data-structures/get-table-rows.ds.js'; import { FilteringFieldsDs } from '../table-datastructures.js'; +import { attachForeignColumnNames } from '../utils/attach-foreign-column-names.util.js'; +import { buildTableSettingsForResponse } from '../utils/build-table-settings-for-response.util.js'; +import { extractForeignKeysFromWidgets } from '../utils/extract-foreign-keys-from-widgets.util.js'; +import { filterForeignKeysByReadPermission } from '../utils/filter-foreign-keys-by-permission.util.js'; import { findAutocompleteFieldsUtil } from '../utils/find-autocomplete-fields.util.js'; import { findAvailableFields } from '../utils/find-available-fields.utils.js'; import { findFilteringFieldsUtil, parseFilteringFieldsFromBodyData } from '../utils/find-filtering-fields.util.js'; @@ -40,13 +41,8 @@ import { findOrderingFieldUtil } from '../utils/find-ordering-field.util.js'; import { formFullTableStructure } from '../utils/form-full-table-structure.js'; import { isHexString } from '../utils/is-hex-string.js'; import { processRowsUtil } from '../utils/process-found-rows-util.js'; -import { CedarPermissionsService } from '../../cedar-authorization/cedar-permissions.service.js'; +import { getUserEmailForAgent, validateConnection } from '../utils/validate-connection.util.js'; import { IGetTableRows } from './table-use-cases.interface.js'; -import { validateConnection, getUserEmailForAgent } from '../utils/validate-connection.util.js'; -import { extractForeignKeysFromWidgets } from '../utils/extract-foreign-keys-from-widgets.util.js'; -import { filterForeignKeysByReadPermission } from '../utils/filter-foreign-keys-by-permission.util.js'; -import { attachForeignColumnNames } from '../utils/attach-foreign-column-names.util.js'; -import { buildTableSettingsForResponse } from '../utils/build-table-settings-for-response.util.js'; @Injectable() export class GetTableRowsUseCase extends AbstractUseCase implements IGetTableRows { @@ -166,22 +162,31 @@ export class GetTableRowsUseCase extends AbstractUseCase 0) { tableForeignKeys = await Promise.all( tableForeignKeys.map((el) => attachForeignColumnNames( - el, userEmail, connectionId, dao, - this._dbContext.tableSettingsRepository.findTableSettingsPure.bind(this._dbContext.tableSettingsRepository), + el, + userEmail, + connectionId, + dao, + this._dbContext.tableSettingsRepository.findTableSettingsPure.bind( + this._dbContext.tableSettingsRepository, + ), ).catch(() => el), ), ); diff --git a/backend/src/entities/table/use-cases/update-row-in-table.use.case.ts b/backend/src/entities/table/use-cases/update-row-in-table.use.case.ts index 0f64be8a3..15252a4e1 100644 --- a/backend/src/entities/table/use-cases/update-row-in-table.use.case.ts +++ b/backend/src/entities/table/use-cases/update-row-in-table.use.case.ts @@ -6,39 +6,34 @@ import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/help import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; -import { - AmplitudeEventTypeEnum, - LogOperationTypeEnum, - OperationResultStatusEnum, -} from '../../../enums/index.js'; +import { AmplitudeEventTypeEnum, LogOperationTypeEnum, OperationResultStatusEnum } from '../../../enums/index.js'; import { TableActionEventEnum } from '../../../enums/table-action-event-enum.js'; import { ExceptionOperations } from '../../../exceptions/custom-exceptions/exception-operation.js'; import { UnknownSQLException } from '../../../exceptions/custom-exceptions/unknown-sql-exception.js'; import { Messages } from '../../../exceptions/text/messages.js'; -import { - compareArrayElements, - isObjectEmpty, - toPrettyErrorsMsg, -} from '../../../helpers/index.js'; +import { compareArrayElements, isObjectEmpty, toPrettyErrorsMsg } from '../../../helpers/index.js'; import { AmplitudeService } from '../../amplitude/amplitude.service.js'; +import { CedarPermissionsService } from '../../cedar-authorization/cedar-permissions.service.js'; import { isTestConnectionUtil } from '../../connection/utils/is-test-connection-util.js'; import { TableActionActivationService } from '../../table-actions/table-actions-module/table-action-activation.service.js'; import { TableLogsService } from '../../table-logs/table-logs.service.js'; import { UpdateRowInTableDs } from '../application/data-structures/update-row-in-table.ds.js'; import { ReferencedTableNamesAndColumnsDs, TableRowRODs } from '../table-datastructures.js'; -import { convertBinaryDataInRowUtil } from '../utils/convert-binary-data-in-row.util.js'; +import { attachForeignColumnNames } from '../utils/attach-foreign-column-names.util.js'; +import { buildTableSettingsForResponse } from '../utils/build-table-settings-for-response.util.js'; +import { convertHexDataInRowUtil } from '../utils/convert-hex-data-in-row.util.js'; +import { extractForeignKeysFromWidgets } from '../utils/extract-foreign-keys-from-widgets.util.js'; +import { filterForeignKeysByReadPermission } from '../utils/filter-foreign-keys-by-permission.util.js'; import { formFullTableStructure } from '../utils/form-full-table-structure.js'; import { hashPasswordsInRowUtil } from '../utils/hash-passwords-in-row.util.js'; +import { + enrichReferencedTablesWithDisplayNames, + filterReferencedTablesByPermission, +} from '../utils/process-referenced-tables.util.js'; import { processUuidsInRowUtil } from '../utils/process-uuids-in-row-util.js'; import { removePasswordsFromRowsUtil } from '../utils/remove-password-from-row.util.js'; -import { CedarPermissionsService } from '../../cedar-authorization/cedar-permissions.service.js'; +import { getUserEmailForAgent, validateConnection } from '../utils/validate-connection.util.js'; import { IUpdateRowInTable } from './table-use-cases.interface.js'; -import { validateConnection, getUserEmailForAgent } from '../utils/validate-connection.util.js'; -import { extractForeignKeysFromWidgets } from '../utils/extract-foreign-keys-from-widgets.util.js'; -import { filterForeignKeysByReadPermission } from '../utils/filter-foreign-keys-by-permission.util.js'; -import { attachForeignColumnNames } from '../utils/attach-foreign-column-names.util.js'; -import { filterReferencedTablesByPermission, enrichReferencedTablesWithDisplayNames } from '../utils/process-referenced-tables.util.js'; -import { buildTableSettingsForResponse } from '../utils/build-table-settings-for-response.util.js'; @Injectable() export class UpdateRowInTableUseCase @@ -101,7 +96,13 @@ export class UpdateRowInTableUseCase const builtDAOsTableSettings = buildDAOsTableSettingsDs(tableSettings, personalTableSettings); - await filterReferencedTablesByPermission(referencedTableNamesAndColumns, userId, connectionId, masterPwd, this.cedarPermissions); + await filterReferencedTablesByPermission( + referencedTableNamesAndColumns, + userId, + connectionId, + masterPwd, + this.cedarPermissions, + ); const referencedTableNamesAndColumnsWithTablesDisplayNames = await enrichReferencedTablesWithDisplayNames( referencedTableNamesAndColumns, connectionId, @@ -132,14 +133,21 @@ export class UpdateRowInTableUseCase let foreignKeysWithAutocompleteColumns: Array = []; tableForeignKeys = await filterForeignKeysByReadPermission( - tableForeignKeys, userId, connectionId, masterPwd, this.cedarPermissions, + tableForeignKeys, + userId, + connectionId, + masterPwd, + this.cedarPermissions, ); if (tableForeignKeys && tableForeignKeys.length > 0) { foreignKeysWithAutocompleteColumns = await Promise.all( tableForeignKeys.map((el) => attachForeignColumnNames( - el, userEmail, connectionId, dao, + el, + userEmail, + connectionId, + dao, this._dbContext.tableSettingsRepository.findTableSettings.bind(this._dbContext.tableSettingsRepository), ).catch(() => el as ForeignKeyWithAutocompleteColumnsDS), ), @@ -196,11 +204,11 @@ export class UpdateRowInTableUseCase try { row = await hashPasswordsInRowUtil(row, tableWidgets); row = processUuidsInRowUtil(row, tableWidgets); + row = convertHexDataInRowUtil(row, tableStructure); await dao.updateRowInTable(tableName, row, primaryKey, userEmail); operationResult = OperationResultStatusEnum.successfully; let updatedRow = await dao.getRowByPrimaryKey(tableName, futurePrimaryKey, builtDAOsTableSettings, userEmail); updatedRow = removePasswordsFromRowsUtil(updatedRow, tableWidgets); - updatedRow = convertBinaryDataInRowUtil(updatedRow, tableStructure); return { row: updatedRow, foreignKeys: foreignKeysWithAutocompleteColumns, @@ -252,5 +260,4 @@ export class UpdateRowInTableUseCase ); } } - } diff --git a/backend/src/entities/table/utils/convert-binary-data-in-row.util.ts b/backend/src/entities/table/utils/convert-binary-data-in-row.util.ts deleted file mode 100644 index 40da77b2c..000000000 --- a/backend/src/entities/table/utils/convert-binary-data-in-row.util.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js'; -import { binaryToHex, isBinary } from '../../../helpers/index.js'; - -export function convertBinaryDataInRowUtil( - row: Record, - structure: Array, -): Record { - const binaryColumns = structure - .map((el) => { - return { - column_name: el.column_name, - data_type: el.data_type, - }; - }) - .filter((el) => { - return isBinary(el.data_type); - }); - if (binaryColumns.length <= 0) { - return row; - } - - for (const column of binaryColumns) { - if (row[column.column_name]) { - row[column.column_name] = binaryToHex(row[column.column_name] as string); - } - } - return row; -} diff --git a/backend/src/entities/table/utils/convert-hex-data-in-primary-key.util.ts b/backend/src/entities/table/utils/convert-hex-data-in-primary-key.util.ts index 08dcb57d5..76e3bf5fe 100644 --- a/backend/src/entities/table/utils/convert-hex-data-in-primary-key.util.ts +++ b/backend/src/entities/table/utils/convert-hex-data-in-primary-key.util.ts @@ -1,5 +1,5 @@ import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js'; -import { hexToBinary } from '../../../helpers/binary-to-hex.js'; +import { toBinaryBuffer } from '../../../helpers/binary-to-hex.js'; import { isBinary } from '../../../helpers/index.js'; export function convertHexDataInPrimaryKeyUtil( @@ -10,9 +10,8 @@ export function convertHexDataInPrimaryKeyUtil( for (const column of binaryColumns) { const columnValue = primaryKey[column.column_name]; - if (columnValue) { - primaryKey[column.column_name] = hexToBinary(columnValue as string); - } + if (columnValue == null || columnValue === '') continue; + primaryKey[column.column_name] = toBinaryBuffer(columnValue); } return primaryKey; diff --git a/backend/src/entities/table/utils/convert-hex-data-in-row.util.ts b/backend/src/entities/table/utils/convert-hex-data-in-row.util.ts index c9cd61b6f..efb5fb25b 100644 --- a/backend/src/entities/table/utils/convert-hex-data-in-row.util.ts +++ b/backend/src/entities/table/utils/convert-hex-data-in-row.util.ts @@ -1,5 +1,5 @@ import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js'; -import { hexToBinary } from '../../../helpers/binary-to-hex.js'; +import { toBinaryBuffer } from '../../../helpers/binary-to-hex.js'; import { isBinary } from '../../../helpers/index.js'; export function convertHexDataInRowUtil( @@ -10,9 +10,8 @@ export function convertHexDataInRowUtil( for (const column of binaryColumns) { const columnValue = row[column.column_name]; - if (columnValue) { - row[column.column_name] = hexToBinary(columnValue as string); - } + if (columnValue == null || columnValue === '') continue; + row[column.column_name] = toBinaryBuffer(columnValue); } return row; diff --git a/backend/src/entities/table/utils/process-found-rows-util.ts b/backend/src/entities/table/utils/process-found-rows-util.ts index 63554674f..cdbe33133 100644 --- a/backend/src/entities/table/utils/process-found-rows-util.ts +++ b/backend/src/entities/table/utils/process-found-rows-util.ts @@ -1,8 +1,6 @@ -import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js'; import { FoundRowsDS } from '@rocketadmin/shared-code/src/data-access-layer/shared/data-structures/found-rows.ds.js'; import sjson from 'secure-json-parse'; import { WidgetTypeEnum } from '../../../enums/widget-type.enum.js'; -import { binaryToHex, isBinary } from '../../../helpers/binary-to-hex.js'; import { Constants } from '../../../helpers/constants/constants.js'; import { getPropertyValueByDescriptor } from '../../../helpers/get-property-value-by-descriptor.js'; import { getValuesBetweenCurlies, replaceTextInCurlies } from '../../../helpers/operate-values-between-curlies.js'; @@ -13,11 +11,9 @@ import { TableWidgetEntity } from '../../widget/table-widget.entity.js'; export function processRowsUtil( rows: FoundRowsDS, tableWidgets: Array, - structure: Array, customTableFields: Array, ): FoundRowsDS { const passwordWidgets = tableWidgets?.filter((el) => el.widget_type === WidgetTypeEnum.Password); - const binaryColumns = structure?.filter((el) => isBinary(el.data_type)); const parsedRows: FoundRowsDS = sjson.parse(JSON.stringify(rows), null, { protoAction: 'remove', @@ -47,12 +43,6 @@ export function processRowsUtil( row['#autoadmin:customFields'] = customFields; } - binaryColumns?.forEach((column) => { - if (row[column.column_name]) { - row[column.column_name] = binaryToHex(row[column.column_name] as string); - } - }); - passwordWidgets?.forEach((widget) => { if (row[widget.field_name]) { row[widget.field_name] = Constants.REMOVED_PASSWORD_VALUE; diff --git a/backend/src/helpers/binary-to-hex.ts b/backend/src/helpers/binary-to-hex.ts index 1b9024ee7..cad70f17f 100644 --- a/backend/src/helpers/binary-to-hex.ts +++ b/backend/src/helpers/binary-to-hex.ts @@ -20,6 +20,19 @@ export function hexToBinary(hexSource: string): Buffer { return Buffer.from(hexSource, 'hex'); } +export function toBinaryBuffer(value: unknown): Buffer { + if (Buffer.isBuffer(value)) return value; + if (value instanceof Uint8Array) return Buffer.from(value); + if (typeof value === 'string') return hexToBinary(value); + if (value && typeof value === 'object') { + const v = value as { type?: string; data?: unknown }; + if (v.type === 'Buffer' && Array.isArray(v.data)) return Buffer.from(v.data as number[]); + if (Array.isArray(v.data)) return Buffer.from(v.data as number[]); + return Buffer.from(Object.values(value as Record)); + } + return hexToBinary(String(value)); +} + export function isBinary(type: string): boolean { return Constants.BINARY_DATATYPES.includes(type.toLowerCase()); } diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-postgres-with-binary-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-postgres-with-binary-e2e.test.ts index 85d469908..de5cb5e70 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-postgres-with-binary-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-postgres-with-binary-e2e.test.ts @@ -12,7 +12,7 @@ import { ApplicationModule } from '../../../src/app.module.js'; import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; -import { hexToBinary } from '../../../src/helpers/binary-to-hex.js'; +import { binaryToHex, hexToBinary } from '../../../src/helpers/binary-to-hex.js'; import { Cacher } from '../../../src/helpers/cache/cacher.js'; import { DatabaseModule } from '../../../src/shared/database/database.module.js'; import { DatabaseService } from '../../../src/shared/database/database.service.js'; @@ -118,11 +118,10 @@ test.serial(`${currentTest} should return list of tables in connection`, async ( t.is(getTableRowsResponse.status, 200); const getTableRowsRO = JSON.parse(getTableRowsResponse.text); + const searchHex = binaryToHex(getTableRowsRO.rows[0][testTableColumnName]); const getTableRowsWithSearchResponse = await request(app.getHttpServer()) - .get( - `/table/rows/${createConnectionRO.id}?tableName=${testTableName}&search=${getTableRowsRO.rows[0][testTableColumnName]}`, - ) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}&search=${searchHex}`) .set('Cookie', firstUserToken) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); @@ -130,7 +129,7 @@ test.serial(`${currentTest} should return list of tables in connection`, async ( const getTableRowsWithSearchRO = JSON.parse(getTableRowsWithSearchResponse.text); t.is(getTableRowsResponse.status, 200); t.is(getTableRowsWithSearchRO.rows.length, 1); - t.is(getTableRowsWithSearchRO.rows[0][testTableColumnName], getTableRowsRO.rows[0][testTableColumnName]); + t.deepEqual(getTableRowsWithSearchRO.rows[0][testTableColumnName], getTableRowsRO.rows[0][testTableColumnName]); t.pass(); } catch (e) { diff --git a/backend/test/ava-tests/saas-tests/postgres-with-binary-e2e.test.ts b/backend/test/ava-tests/saas-tests/postgres-with-binary-e2e.test.ts index 3220a7416..22cc53904 100644 --- a/backend/test/ava-tests/saas-tests/postgres-with-binary-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/postgres-with-binary-e2e.test.ts @@ -12,7 +12,7 @@ import { ApplicationModule } from '../../../src/app.module.js'; import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; -import { hexToBinary } from '../../../src/helpers/binary-to-hex.js'; +import { binaryToHex, hexToBinary } from '../../../src/helpers/binary-to-hex.js'; import { Cacher } from '../../../src/helpers/cache/cacher.js'; import { DatabaseModule } from '../../../src/shared/database/database.module.js'; import { DatabaseService } from '../../../src/shared/database/database.service.js'; @@ -112,18 +112,17 @@ test.serial(`${currentTest} should return list of tables in connection`, async ( t.is(getTableRowsResponse.status, 200); const getTableRowsRO = JSON.parse(getTableRowsResponse.text); + const searchHex = binaryToHex(getTableRowsRO.rows[0][testTableColumnName]); const getTableRowsWithSearchResponse = await request(app.getHttpServer()) - .get( - `/table/rows/${createConnectionRO.id}?tableName=${testTableName}&search=${getTableRowsRO.rows[0][testTableColumnName]}`, - ) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}&search=${searchHex}`) .set('Cookie', firstUserToken) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); t.is(getTableRowsResponse.status, 200); const getTableRowsWithSearchRO = JSON.parse(getTableRowsWithSearchResponse.text); t.is(getTableRowsWithSearchRO.rows.length, 1); - t.is(getTableRowsWithSearchRO.rows[0][testTableColumnName], getTableRowsRO.rows[0][testTableColumnName]); + t.deepEqual(getTableRowsWithSearchRO.rows[0][testTableColumnName], getTableRowsRO.rows[0][testTableColumnName]); t.pass(); } catch (e) { diff --git a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts index 2c6e3793a..739c9bed8 100644 --- a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts +++ b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts @@ -611,11 +611,17 @@ export class DbTableRowEditComponent implements OnInit { } updateField = (updatedValue: any, field: string) => { - if (typeof updatedValue === 'object' && updatedValue !== null) { - for (const prop of Object.getOwnPropertyNames(this.tableRowValues[field])) { - delete this.tableRowValues[field][prop]; + const existing = this.tableRowValues[field]; + if ( + typeof updatedValue === 'object' && + updatedValue !== null && + typeof existing === 'object' && + existing !== null + ) { + for (const prop of Object.getOwnPropertyNames(existing)) { + delete existing[prop]; } - Object.assign(this.tableRowValues[field], updatedValue); + Object.assign(existing, updatedValue); } else { this.tableRowValues[field] = updatedValue; } diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.css b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.css new file mode 100644 index 000000000..3b1f34518 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.css @@ -0,0 +1,15 @@ +.filter-row { + display: flex; + gap: 8px; + align-items: flex-start; + width: 100%; +} + +.comparator-field { + flex: 0 0 auto; + min-width: 150px; +} + +.value-field { + flex: 1; +} diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html new file mode 100644 index 000000000..c0837cb60 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html @@ -0,0 +1,26 @@ +
+ + + equal + contains + starts with + is empty + + + + @if (filterMode !== 'empty') { + + {{normalizedLabel()}} (hex) + + @if (hexInput.errors?.isInvalidHex) { + Invalid hex. + } + + } +
diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts new file mode 100644 index 000000000..eee282570 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts @@ -0,0 +1,84 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { BinaryFilterComponent } from './binary.component'; + +describe('BinaryFilterComponent', () => { + let component: BinaryFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BinaryFilterComponent, BrowserAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(BinaryFilterComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('defaults to eq filter mode with empty hex', () => { + fixture.detectChanges(); + expect(component.filterMode).toBe('eq'); + expect(component.hexValue).toBe(''); + }); + + it('normalizes the incoming hex value through bytes on init', () => { + component.value = '48656c6c6f'; + component.ngOnInit(); + expect(component.hexValue).toBe('48656c6c6f'); + }); + + it('drops a malformed incoming hex value to empty on init', () => { + component.value = 'zz'; + component.ngOnInit(); + expect(component.hexValue).toBe(''); + }); + + it('emits the hex string and current comparator on hex change', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.detectChanges(); + + component.onHexValueChange('abcdef'); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith('abcdef'); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); + }); + + it('switches to empty mode and clears hex', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.detectChanges(); + + component.hexValue = 'abcdef'; + component.onFilterModeChange('empty'); + + expect(component.hexValue).toBe(''); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('empty'); + expect(component.onFieldChange.emit).toHaveBeenCalledWith(''); + }); + + it('emits contains comparator and re-emits current hex', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.detectChanges(); + + component.hexValue = 'abcdef'; + component.onFilterModeChange('contains'); + + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('contains'); + expect(component.onFieldChange.emit).toHaveBeenCalledWith('abcdef'); + }); + + it('emits startswith comparator', () => { + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.detectChanges(); + + component.onFilterModeChange('startswith'); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('startswith'); + }); +}); diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts new file mode 100644 index 000000000..be6ce664d --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts @@ -0,0 +1,52 @@ +import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { HexValidationDirective } from 'src/app/directives/hexValidator.directive'; +import { bytesToHex, hexStringToBytes } from 'src/app/lib/binary'; +import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; + +export type BinaryFilterMode = 'eq' | 'contains' | 'startswith' | 'empty'; + +@Component({ + selector: 'app-filter-binary', + templateUrl: './binary.component.html', + styleUrls: ['./binary.component.css'], + imports: [FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, HexValidationDirective], +}) +export class BinaryFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { + @Input() value: string; + @ViewChild('inputElement') inputElement: ElementRef; + + public filterMode: BinaryFilterMode = 'eq'; + public hexValue = ''; + + override ngOnInit(): void { + this.hexValue = bytesToHex(hexStringToBytes(this.value ?? '')); + } + + ngAfterViewInit(): void { + if (this.autofocus() && this.inputElement) { + setTimeout(() => this.inputElement.nativeElement.focus(), 100); + } + } + + onFilterModeChange(mode: BinaryFilterMode): void { + this.filterMode = mode; + if (mode === 'empty') { + this.hexValue = ''; + this.onComparatorChange.emit('empty'); + this.onFieldChange.emit(''); + return; + } + this.onComparatorChange.emit(mode); + this.onFieldChange.emit(this.hexValue); + } + + onHexValueChange(hex: string): void { + this.hexValue = hex; + this.onFieldChange.emit(hex); + this.onComparatorChange.emit(this.filterMode); + } +} diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css index 327f04173..57610ddd6 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css +++ b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css @@ -19,3 +19,9 @@ grid-template-columns: 1fr 1fr; grid-column-gap: 16px; } + +.between-fields { + display: flex; + flex-direction: column; + gap: 8px; +} diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html index 4fabdca6f..4e999a8b3 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html @@ -11,10 +11,55 @@ before on or after on or before + between + in - @if (!isPresetMode()) { + @if (filterMode === 'between') { +
+
+ + {{normalizedLabel()}} (from date) + + + + + (from time) + + +
+ +
+ + {{normalizedLabel()}} (to date) + + + + + (to time) + + +
+
+ } @else if (filterMode === 'in') { + + {{normalizedLabel()}} (comma-separated ISO datetimes) + + + } @else if (!isPresetMode()) {
{{normalizedLabel()}} (date) diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts index f455f2b24..e4fc411e1 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts +++ b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts @@ -127,4 +127,62 @@ describe('DateTimeFilterComponent', () => { expect(component.time).toEqual('00:00:00'); expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T00:00:00Z'); }); + + it('should emit between comparator with [lower, upper] ISO strings when BETWEEN bounds change', () => { + const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); + const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); + component.onFilterModeChange('between'); + + component.lowerDate = '2024-01-01'; + component.lowerTime = '00:00:00'; + component.onBetweenLowerChange(); + + expect(comparatorEvent).toHaveBeenCalledWith('between'); + expect(fieldEvent).toHaveBeenLastCalledWith(['2024-01-01T00:00:00Z', null]); + + component.upperDate = '2024-01-31'; + component.upperTime = '23:59:59'; + component.onBetweenUpperChange(); + + expect(fieldEvent).toHaveBeenLastCalledWith(['2024-01-01T00:00:00Z', '2024-01-31T23:59:59Z']); + }); + + it('should restore BETWEEN bounds from a two-element array on init', () => { + component.value = ['2024-01-01T00:00:00Z', '2024-01-31T23:59:59Z']; + component.filterMode = 'between'; + component.ngOnInit(); + + expect(component.lowerDate).toEqual('2024-01-01'); + expect(component.lowerTime).toEqual('00:00:00'); + expect(component.upperDate).toEqual('2024-01-31'); + expect(component.upperTime).toEqual('23:59:59'); + }); + + it('should parse comma-separated text into array on IN text change', () => { + const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); + const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); + component.onFilterModeChange('in'); + + component.onInTextChange('2024-01-01T00:00:00Z, 2024-02-01T00:00:00Z'); + + expect(comparatorEvent).toHaveBeenCalledWith('in'); + expect(fieldEvent).toHaveBeenLastCalledWith(['2024-01-01T00:00:00Z', '2024-02-01T00:00:00Z']); + }); + + it('should emit undefined when IN text is empty', () => { + const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); + component.onFilterModeChange('in'); + + component.onInTextChange(' '); + + expect(fieldEvent).toHaveBeenLastCalledWith(undefined); + }); + + it('should restore IN text from array value on init', () => { + component.value = ['2024-01-01T00:00:00Z', '2024-02-01T00:00:00Z']; + component.filterMode = 'in'; + component.ngOnInit(); + + expect(component.inValueText).toBe('2024-01-01T00:00:00Z, 2024-02-01T00:00:00Z'); + }); }); diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts index 1c976105a..722f3a093 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts @@ -14,17 +14,34 @@ import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule], }) export class DateTimeFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { - @Input() value: string; + @Input() value: string | string[]; @ViewChild('inputElement') inputElement: ElementRef; public filterMode: string = 'last_day'; public date: string; public time: string; + public lowerDate: string; + public lowerTime: string; + public upperDate: string; + public upperTime: string; + + public inValueText: string = ''; + private _presetModes = ['last_hour', 'last_day', 'last_week', 'last_month', 'last_year']; ngOnInit(): void { - if (this.value) { + if (this.filterMode === 'between' && Array.isArray(this.value)) { + this._restoreBetween(this.value); + return; + } + + if (this.filterMode === 'in' && Array.isArray(this.value)) { + this.inValueText = this.value.join(', '); + return; + } + + if (typeof this.value === 'string' && this.value) { const datetime = new Date(this.value); this.date = format(datetime, 'yyyy-MM-dd'); this.time = format(datetime, 'HH:mm:ss'); @@ -33,7 +50,9 @@ export class DateTimeFilterComponent extends BaseFilterFieldComponent implements } ngAfterViewInit(): void { - if (this.value) { + if (this.filterMode === 'between' || this.filterMode === 'in') { + this.onComparatorChange.emit(this.filterMode); + } else if (this.value) { this.onComparatorChange.emit(this.filterMode); } else { const value = this._computePresetValue(this.filterMode); @@ -49,19 +68,34 @@ export class DateTimeFilterComponent extends BaseFilterFieldComponent implements } onFilterModeChange(mode: string): void { + const previous = this.filterMode; this.filterMode = mode; if (this._presetModes.includes(mode)) { const value = this._computePresetValue(mode); this.onFieldChange.emit(value); this.onComparatorChange.emit('gte'); - } else { - this.onComparatorChange.emit(mode); - if (this.date) { - const time = this.time || '00:00'; - const datetime = `${this.date}T${time}Z`; - this.onFieldChange.emit(datetime); + return; + } + + if (mode === 'in' || mode === 'between') { + if (previous !== mode) { + this.inValueText = ''; + this.lowerDate = undefined; + this.lowerTime = undefined; + this.upperDate = undefined; + this.upperTime = undefined; + this.onFieldChange.emit(undefined); } + this.onComparatorChange.emit(mode); + return; + } + + this.onComparatorChange.emit(mode); + if (this.date) { + const time = this.time || '00:00'; + const datetime = `${this.date}T${time}Z`; + this.onFieldChange.emit(datetime); } } @@ -78,10 +112,59 @@ export class DateTimeFilterComponent extends BaseFilterFieldComponent implements this.onComparatorChange.emit(this.filterMode); } + onBetweenLowerChange(): void { + const lower = this._composeDateTime(this.lowerDate, this.lowerTime); + const upper = this._composeDateTime(this.upperDate, this.upperTime); + this.onFieldChange.emit([lower, upper]); + this.onComparatorChange.emit('between'); + } + + onBetweenUpperChange(): void { + const lower = this._composeDateTime(this.lowerDate, this.lowerTime); + const upper = this._composeDateTime(this.upperDate, this.upperTime); + this.onFieldChange.emit([lower, upper]); + this.onComparatorChange.emit('between'); + } + + onInTextChange(text: string): void { + this.inValueText = text; + const parts = text + .split(',') + .map((v) => v.trim()) + .filter((v) => v.length > 0); + + if (parts.length === 0) { + this.onFieldChange.emit(undefined); + return; + } + + this.onFieldChange.emit(parts); + this.onComparatorChange.emit('in'); + } + isPresetMode(): boolean { return this._presetModes.includes(this.filterMode); } + private _restoreBetween(value: string[]): void { + if (value[0]) { + const lower = new Date(value[0]); + this.lowerDate = format(lower, 'yyyy-MM-dd'); + this.lowerTime = format(lower, 'HH:mm:ss'); + } + if (value[1]) { + const upper = new Date(value[1]); + this.upperDate = format(upper, 'yyyy-MM-dd'); + this.upperTime = format(upper, 'HH:mm:ss'); + } + } + + private _composeDateTime(date: string, time: string): string | null { + if (!date) return null; + const t = time || '00:00:00'; + return `${date}T${t}Z`; + } + private _computePresetValue(mode: string): string { const now = new Date(); let targetDate: Date; diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css index f9b0eb8b0..84f358170 100644 --- a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css +++ b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css @@ -14,3 +14,18 @@ flex: 1; min-width: 0; } + +.between-fields { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.between-fields ::ng-deep > ndc-dynamic { + flex: 1; + min-width: 0; +} + +.between-fields ::ng-deep mat-form-field { + width: 100%; +} diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html index a9b226b84..73509c882 100644 --- a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html @@ -7,13 +7,43 @@ -
- -
+ @if (comparator === 'in') { + + {{ normalizedLabel() }} (comma-separated) + + + } @else if (comparator === 'between') { +
+ + +
+ } @else { +
+ +
+ }
diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts index 6e989c923..191f97d35 100644 --- a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts +++ b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { vi } from 'vitest'; +import { DateFilterComponent } from '../date/date.component'; import { NumberFilterComponent } from '../number/number.component'; import { TextFilterComponent } from '../text/text.component'; import { DefaultFilterComponent } from './default-filter.component'; @@ -39,7 +40,14 @@ describe('DefaultFilterComponent', () => { it('should expose number comparator options when wrapping a number-type filter', () => { component.valueComponent = NumberFilterComponent; - expect(component.comparatorOptions.map((o) => o.value)).toEqual(['eq', 'gt', 'lt', 'gte', 'lte']); + expect(component.comparatorOptions.map((o) => o.value)).toEqual(['eq', 'gt', 'lt', 'gte', 'lte', 'in', 'between']); + }); + + it('should expose in/between for datetime inner components', () => { + component.valueComponent = DateFilterComponent; + const values = component.comparatorOptions.map((o) => o.value); + expect(values).toContain('in'); + expect(values).toContain('between'); }); it('should emit initial comparator on view init', () => { @@ -80,4 +88,76 @@ describe('DefaultFilterComponent', () => { component.comparator = 'empty'; expect(component.innerInputs.readonly).toBe(true); }); + + it('should parse comma-separated text into an array on IN text change', () => { + const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); + component.valueComponent = NumberFilterComponent; + component.comparator = 'in'; + component.onInTextChange('1, 2,3 , 4'); + + expect(component.inValueText).toBe('1, 2,3 , 4'); + expect(valueSpy).toHaveBeenCalledWith(['1', '2', '3', '4']); + }); + + it('should emit undefined when IN text is empty', () => { + const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); + component.valueComponent = NumberFilterComponent; + component.comparator = 'in'; + component.onInTextChange(' '); + + expect(valueSpy).toHaveBeenCalledWith(undefined); + }); + + it('should restore inValueText from an array value on init when comparator is in', () => { + component.valueComponent = NumberFilterComponent; + component.comparator = 'in'; + component.value = ['1', '2', '3']; + component.ngOnInit(); + + expect(component.inValueText).toBe('1, 2, 3'); + }); + + it('should emit two-element array when BETWEEN bounds change', () => { + const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); + component.valueComponent = NumberFilterComponent; + component.comparator = 'between'; + + component.onBetweenLowerChange('10'); + expect(valueSpy).toHaveBeenLastCalledWith(['10', undefined]); + + component.onBetweenUpperChange('20'); + expect(valueSpy).toHaveBeenLastCalledWith(['10', '20']); + }); + + it('should restore BETWEEN bounds from a two-element array on init', () => { + component.valueComponent = NumberFilterComponent; + component.comparator = 'between'; + component.value = ['5', '15']; + component.ngOnInit(); + + expect(component.betweenLower).toBe('5'); + expect(component.betweenUpper).toBe('15'); + }); + + it('should clear single-value state when switching to IN', () => { + component.valueComponent = NumberFilterComponent; + component.value = '42'; + const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); + + component.onComparatorSelect('in'); + + expect(component.comparator).toBe('in'); + expect(valueSpy).toHaveBeenCalledWith(undefined); + }); + + it('should clear single-value state when switching to BETWEEN', () => { + component.valueComponent = NumberFilterComponent; + component.value = '42'; + const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); + + component.onComparatorSelect('between'); + + expect(component.comparator).toBe('between'); + expect(valueSpy).toHaveBeenCalledWith(undefined); + }); }); diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts index f739c8847..f09d05461 100644 --- a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { AfterViewInit, Component, Input, OnInit, Type } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { DynamicModule } from 'ng-dynamic-component'; import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; @@ -22,20 +23,41 @@ const NUMBER_COMPARATORS = [ { value: 'lt', label: 'less than' }, { value: 'gte', label: 'greater than or equal' }, { value: 'lte', label: 'less than or equal' }, + { value: 'in', label: 'in' }, + { value: 'between', label: 'between' }, ]; @Component({ selector: 'app-filter-default', templateUrl: './default-filter.component.html', styleUrls: ['./default-filter.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule, DynamicModule, SignalComponentIoModule], + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + DynamicModule, + SignalComponentIoModule, + ], }) export class DefaultFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { @Input() value: any; @Input() comparator: string = 'eq'; @Input() valueComponent: Type; - ngOnInit(): void {} + public inValueText: string = ''; + public betweenLower: any; + public betweenUpper: any; + + ngOnInit(): void { + if (this.comparator === 'in' && Array.isArray(this.value)) { + this.inValueText = this.value.join(', '); + } else if (this.comparator === 'between' && Array.isArray(this.value)) { + this.betweenLower = this.value[0]; + this.betweenUpper = this.value[1]; + } + } ngAfterViewInit(): void { this.onComparatorChange.emit(this.comparator); @@ -58,12 +80,44 @@ export class DefaultFilterComponent extends BaseFilterFieldComponent implements }; } + get lowerInputs(): Record { + return { + key: `${this.key()}-lower`, + label: `${this.label()} (from)`, + value: this.betweenLower, + structure: this.structure(), + relations: this.relations(), + autofocus: this.autofocus(), + }; + } + + get upperInputs(): Record { + return { + key: `${this.key()}-upper`, + label: `${this.label()} (to)`, + value: this.betweenUpper, + structure: this.structure(), + relations: this.relations(), + }; + } + onComparatorSelect(comparator: string): void { + const previous = this.comparator; this.comparator = comparator; + if (comparator === 'empty') { this.value = ''; this.onFieldChange.emit(''); + } else if (comparator === 'in' || comparator === 'between') { + if (previous !== comparator) { + this.value = undefined; + this.inValueText = ''; + this.betweenLower = undefined; + this.betweenUpper = undefined; + this.onFieldChange.emit(undefined); + } } + this.onComparatorChange.emit(comparator); } @@ -71,4 +125,33 @@ export class DefaultFilterComponent extends BaseFilterFieldComponent implements this.value = val; this.onFieldChange.emit(val); }; + + onInTextChange(text: string): void { + this.inValueText = text; + const parts = text + .split(',') + .map((v) => v.trim()) + .filter((v) => v.length > 0); + + if (parts.length === 0) { + this.value = undefined; + this.onFieldChange.emit(undefined); + return; + } + + this.value = parts; + this.onFieldChange.emit(parts); + } + + onBetweenLowerChange = (val: any): void => { + this.betweenLower = val; + this.value = [this.betweenLower, this.betweenUpper]; + this.onFieldChange.emit(this.value); + }; + + onBetweenUpperChange = (val: any): void => { + this.betweenUpper = val; + this.value = [this.betweenLower, this.betweenUpper]; + this.onFieldChange.emit(this.value); + }; } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.css b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.css new file mode 100644 index 000000000..f0f339e4f --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.css @@ -0,0 +1,4 @@ +.binary-edit { + width: 100%; + margin-bottom: 24px; +} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html new file mode 100644 index 000000000..6f42a8d86 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html @@ -0,0 +1,12 @@ + + {{ normalizedLabel() }} (hex) + + @if (hexContent.errors?.isInvalidHex) { + Invalid hex. + } + diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts new file mode 100644 index 000000000..06d8fc9d9 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts @@ -0,0 +1,80 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { BinaryEditComponent } from './binary.component'; + +describe('BinaryEditComponent', () => { + let component: BinaryEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormsModule, BinaryEditComponent, BrowserAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(BinaryEditComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('parses a server string as char-code-per-byte and emits Buffer-JSON on init', () => { + vi.spyOn(component.onFieldChange, 'emit'); + fixture.componentRef.setInput('value', 'Hello'); + component.ngOnInit(); + // 'Hello' -> bytes 48 65 6c 6c 6f -> hex '48656c6c6f' + expect(component.hexData).toBe('48656c6c6f'); + expect(component.onFieldChange.emit).toHaveBeenCalledWith({ + type: 'Buffer', + data: [0x48, 0x65, 0x6c, 0x6c, 0x6f], + }); + }); + + it('re-emits an incoming Buffer-JSON value as Buffer-JSON on init', () => { + vi.spyOn(component.onFieldChange, 'emit'); + fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] }); + component.ngOnInit(); + expect(component.hexData).toBe('48656c'); + expect(component.onFieldChange.emit).toHaveBeenCalledWith({ + type: 'Buffer', + data: [0x48, 0x65, 0x6c], + }); + }); + + it('emits null and shows empty hex when incoming value is null', () => { + vi.spyOn(component.onFieldChange, 'emit'); + fixture.componentRef.setInput('value', null); + component.ngOnInit(); + expect(component.hexData).toBe(''); + expect(component.onFieldChange.emit).toHaveBeenCalledWith(null); + }); + + it('emits Buffer-JSON when the user types valid hex', () => { + vi.spyOn(component.onFieldChange, 'emit'); + component.hexData = '48656c6c6f'; + component.onHexChange(); + expect(component.onFieldChange.emit).toHaveBeenCalledWith({ + type: 'Buffer', + data: [0x48, 0x65, 0x6c, 0x6c, 0x6f], + }); + expect(component.isInvalidInput).toBe(false); + }); + + it('emits null when the field is cleared', () => { + vi.spyOn(component.onFieldChange, 'emit'); + component.hexData = ''; + component.onHexChange(); + expect(component.onFieldChange.emit).toHaveBeenCalledWith(null); + }); + + it('marks invalid and emits raw string when the user types malformed hex', () => { + vi.spyOn(component.onFieldChange, 'emit'); + component.hexData = 'zz'; + component.onHexChange(); + expect(component.isInvalidInput).toBe(true); + expect(component.onFieldChange.emit).toHaveBeenCalledWith('zz'); + }); +}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts new file mode 100644 index 000000000..d005b9a54 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts @@ -0,0 +1,46 @@ +import { Component, model, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { HexValidationDirective } from 'src/app/directives/hexValidator.directive'; +import { BinaryBufferJson, bytesToHex, hexStringToBytes, parseBinaryValue, toBufferJson } from 'src/app/lib/binary'; +import { hexValidation } from 'src/app/validators/hex.validator'; +import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; + +@Component({ + selector: 'app-edit-binary', + templateUrl: './binary.component.html', + styleUrls: ['./binary.component.css'], + imports: [FormsModule, MatFormFieldModule, MatInputModule, HexValidationDirective], +}) +export class BinaryEditComponent extends BaseEditFieldComponent implements OnInit { + readonly value = model(); + + static type = 'file'; + + public hexData = ''; + public isInvalidInput = false; + + ngOnInit(): void { + super.ngOnInit(); + this.hexData = bytesToHex(parseBinaryValue(this.value())); + this.emitCurrentValue(); + } + + onHexChange(): void { + this.isInvalidInput = !!hexValidation()({ value: this.hexData } as never); + this.emitCurrentValue(); + } + + private emitCurrentValue(): void { + if (!this.hexData) { + this.onFieldChange.emit(null); + return; + } + if (this.isInvalidInput) { + this.onFieldChange.emit(this.hexData); + return; + } + this.onFieldChange.emit(toBufferJson(hexStringToBytes(this.hexData))); + } +} diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.css b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.css new file mode 100644 index 000000000..48a302fb0 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.css @@ -0,0 +1,11 @@ +.binary-view { + display: flex; + align-items: center; + gap: 8px; +} + +.binary-view-value { + font-family: monospace; + font-size: 13px; + word-break: break-all; +} diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html new file mode 100644 index 000000000..0695c8c71 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html @@ -0,0 +1,14 @@ +
+ {{ displayText() }} + @if (hexValue()) { + + } +
diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts new file mode 100644 index 000000000..beaf6e3c6 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { BinaryRecordViewComponent } from './binary.component'; + +describe('BinaryRecordViewComponent', () => { + let component: BinaryRecordViewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BinaryRecordViewComponent, BrowserAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(BinaryRecordViewComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('shows em-dash for empty value', () => { + fixture.detectChanges(); + expect(component.displayText()).toBe('\u2014'); + }); + + it('parses a server string as char-code-per-byte', () => { + fixture.componentRef.setInput('value', 'Hel'); + fixture.detectChanges(); + expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); + expect(component.hexValue()).toBe('48656c'); + }); + + it('parses a Buffer-JSON value to a byte array', () => { + fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] }); + fixture.detectChanges(); + expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); + expect(component.hexValue()).toBe('48656c'); + }); + + it('truncates long hex with ellipsis', () => { + // 40 bytes of 0xaa → 80 hex chars, just at the limit; bump to 50 bytes to trigger truncation. + fixture.componentRef.setInput('value', '\u00aa'.repeat(50)); + fixture.detectChanges(); + expect(component.hexValue()).toBe('aa'.repeat(50)); + expect(component.isTruncated()).toBe(true); + expect(component.displayText()).toBe('aa'.repeat(40) + '\u2026'); + }); +}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts new file mode 100644 index 000000000..9fcaa52eb --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts @@ -0,0 +1,28 @@ +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { Component, computed } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { bytesToHex, parseBinaryValue } from 'src/app/lib/binary'; +import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; + +const MAX_DISPLAY_LENGTH = 80; + +@Component({ + selector: 'app-binary-record-view', + templateUrl: './binary.component.html', + styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './binary.component.css'], + imports: [ClipboardModule, MatButtonModule, MatIconModule, MatTooltipModule], +}) +export class BinaryRecordViewComponent extends BaseRecordViewFieldComponent { + public readonly bytes = computed(() => parseBinaryValue(this.value())); + public readonly hexValue = computed(() => bytesToHex(this.bytes())); + + public readonly displayText = computed(() => { + const hex = this.hexValue(); + if (!hex) return '\u2014'; + return hex.length > MAX_DISPLAY_LENGTH ? hex.substring(0, MAX_DISPLAY_LENGTH) + '\u2026' : hex; + }); + + public readonly isTruncated = computed(() => this.hexValue().length > MAX_DISPLAY_LENGTH); +} diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.css b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.css new file mode 100644 index 000000000..f33bcd18c --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.css @@ -0,0 +1,4 @@ +.binary-value { + font-family: monospace; + font-size: 13px; +} diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html new file mode 100644 index 000000000..e91d24679 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html @@ -0,0 +1,13 @@ +
+ {{ displayText() }} + @if (hexValue()) { + + } +
diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.spec.ts new file mode 100644 index 000000000..9aa681287 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { BinaryDisplayComponent } from './binary.component'; + +describe('BinaryDisplayComponent', () => { + let component: BinaryDisplayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BinaryDisplayComponent, BrowserAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(BinaryDisplayComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('shows em-dash for empty value', () => { + fixture.detectChanges(); + expect(component.displayText()).toBe('\u2014'); + }); + + it('parses a server string as char-code-per-byte', () => { + fixture.componentRef.setInput('value', 'Hel'); + fixture.detectChanges(); + expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); + expect(component.hexValue()).toBe('48656c'); + }); + + it('parses a Buffer-JSON value to a byte array', () => { + fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] }); + fixture.detectChanges(); + expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); + expect(component.hexValue()).toBe('48656c'); + }); + + it('truncates long hex at 20 chars', () => { + fixture.componentRef.setInput('value', '\u00aa'.repeat(30)); + fixture.detectChanges(); + expect(component.displayText()).toBe('aa'.repeat(10) + '\u2026'); + }); +}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.ts new file mode 100644 index 000000000..1c54ca095 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.ts @@ -0,0 +1,26 @@ +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { Component, computed } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { bytesToHex, parseBinaryValue } from 'src/app/lib/binary'; +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; + +const MAX_DISPLAY_LENGTH = 20; + +@Component({ + selector: 'app-binary-display', + templateUrl: './binary.component.html', + styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './binary.component.css'], + imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], +}) +export class BinaryDisplayComponent extends BaseTableDisplayFieldComponent { + public readonly bytes = computed(() => parseBinaryValue(this.value())); + public readonly hexValue = computed(() => bytesToHex(this.bytes())); + + public readonly displayText = computed(() => { + const hex = this.hexValue(); + if (!hex) return '\u2014'; + return hex.length > MAX_DISPLAY_LENGTH ? hex.substring(0, MAX_DISPLAY_LENGTH) + '\u2026' : hex; + }); +} diff --git a/frontend/src/app/consts/filter-types.ts b/frontend/src/app/consts/filter-types.ts index 75bbec5c3..bc33e9624 100644 --- a/frontend/src/app/consts/filter-types.ts +++ b/frontend/src/app/consts/filter-types.ts @@ -3,6 +3,7 @@ import { LongTextFilterComponent } from 'src/app/components/ui-components/filter import { NumberFilterComponent } from 'src/app/components/ui-components/filter-fields/number/number.component'; import { PointFilterComponent } from 'src/app/components/ui-components/filter-fields/point/point.component'; import { TextFilterComponent } from 'src/app/components/ui-components/filter-fields/text/text.component'; +import { BinaryFilterComponent } from '../components/ui-components/filter-fields/binary/binary.component'; import { CountryFilterComponent } from '../components/ui-components/filter-fields/country/country.component'; import { DateFilterComponent } from '../components/ui-components/filter-fields/date/date.component'; import { DateTimeFilterComponent } from '../components/ui-components/filter-fields/date-time/date-time.component'; @@ -25,6 +26,7 @@ export const UIwidgets = { Country: CountryFilterComponent, Date: DateFilterComponent, DateTime: DateTimeFilterComponent, + Binary: BinaryFilterComponent, File: FileFilterComponent, Foreign_key: ForeignKeyFilterComponent, JSON: JsonEditorFilterComponent, @@ -83,8 +85,8 @@ export const filterTypes = { json: JsonEditorFilterComponent, //json-editor jsonb: JsonEditorFilterComponent, //json-editor - //file - bytea: FileFilterComponent, + //binary + bytea: BinaryFilterComponent, //etc money: TextFilterComponent, @@ -136,13 +138,13 @@ export const filterTypes = { //select (select) enum: SelectFilterComponent, - //file - binary: FileFilterComponent, - varbinary: FileFilterComponent, - blob: FileFilterComponent, - tinyblob: FileFilterComponent, - mediumblob: FileFilterComponent, - longblob: FileFilterComponent, + //binary + binary: BinaryFilterComponent, + varbinary: BinaryFilterComponent, + blob: BinaryFilterComponent, + tinyblob: BinaryFilterComponent, + mediumblob: BinaryFilterComponent, + longblob: BinaryFilterComponent, //etc set: TextFilterComponent, //(text) @@ -172,12 +174,12 @@ export const filterTypes = { VARCHAR: TextFilterComponent, NVARCHAR2: TextFilterComponent, - //file - BLOB: FileFilterComponent, - BFILE: FileFilterComponent, - RAW: FileFilterComponent, - 'LONG RAW': FileFilterComponent, - LONG: FileFilterComponent, + //binary + BLOB: BinaryFilterComponent, + BFILE: BinaryFilterComponent, + RAW: BinaryFilterComponent, + 'LONG RAW': BinaryFilterComponent, + LONG: BinaryFilterComponent, 'foreign key': ForeignKeyFilterComponent, }, @@ -210,10 +212,10 @@ export const filterTypes = { smalldatetime: DateTimeFilterComponent, timestamp: DateTimeFilterComponent, - //file - binary: FileFilterComponent, - varbinary: FileFilterComponent, - image: FileFilterComponent, + //binary + binary: BinaryFilterComponent, + varbinary: BinaryFilterComponent, + image: BinaryFilterComponent, // etc money: TextFilterComponent, @@ -241,8 +243,8 @@ export const filterTypes = { regexp: TextFilterComponent, objectid: TextFilterComponent, - //file - binary: FileFilterComponent, + //binary + binary: BinaryFilterComponent, //json //json @@ -258,7 +260,7 @@ export const filterTypes = { null: StaticTextFilterComponent, array: JsonEditorFilterComponent, json: JsonEditorFilterComponent, - binary: FileFilterComponent, + binary: BinaryFilterComponent, }, cassandra: { int: NumberFilterComponent, diff --git a/frontend/src/app/consts/record-edit-types.ts b/frontend/src/app/consts/record-edit-types.ts index 8921e0fee..36a1f437e 100644 --- a/frontend/src/app/consts/record-edit-types.ts +++ b/frontend/src/app/consts/record-edit-types.ts @@ -3,6 +3,7 @@ import { LongTextEditComponent } from 'src/app/components/ui-components/record-e import { NumberEditComponent } from 'src/app/components/ui-components/record-edit-fields/number/number.component'; import { PointEditComponent } from 'src/app/components/ui-components/record-edit-fields/point/point.component'; import { TextEditComponent } from 'src/app/components/ui-components/record-edit-fields/text/text.component'; +import { BinaryEditComponent } from '../components/ui-components/record-edit-fields/binary/binary.component'; import { CodeEditComponent } from '../components/ui-components/record-edit-fields/code/code.component'; import { ColorEditComponent } from '../components/ui-components/record-edit-fields/color/color.component'; import { CountryEditComponent } from '../components/ui-components/record-edit-fields/country/country.component'; @@ -59,6 +60,7 @@ export const UIwidgets = { Country: CountryEditComponent, Date: DateEditComponent, DateTime: DateTimeEditComponent, + Binary: BinaryEditComponent, File: FileEditComponent, Foreign_key: ForeignKeyEditComponent, Image: ImageEditComponent, @@ -125,8 +127,8 @@ export const recordEditTypes = { jsonb: JsonEditorEditComponent, //json-editor ARRAY: JsonEditorEditComponent, - //file - bytea: FileEditComponent, + //binary + bytea: BinaryEditComponent, //etc money: MoneyEditComponent, @@ -178,13 +180,13 @@ export const recordEditTypes = { //select (select) enum: SelectEditComponent, - //file - binary: FileEditComponent, - varbinary: FileEditComponent, - blob: FileEditComponent, - tinyblob: FileEditComponent, - mediumblob: FileEditComponent, - longblob: FileEditComponent, + //binary + binary: BinaryEditComponent, + varbinary: BinaryEditComponent, + blob: BinaryEditComponent, + tinyblob: BinaryEditComponent, + mediumblob: BinaryEditComponent, + longblob: BinaryEditComponent, //etc set: TextEditComponent, //(text) @@ -214,12 +216,12 @@ export const recordEditTypes = { VARCHAR: TextEditComponent, NVARCHAR2: TextEditComponent, - //file - BLOB: FileEditComponent, - BFILE: FileEditComponent, - RAW: FileEditComponent, - 'LONG RAW': FileEditComponent, - LONG: FileEditComponent, + //binary + BLOB: BinaryEditComponent, + BFILE: BinaryEditComponent, + RAW: BinaryEditComponent, + 'LONG RAW': BinaryEditComponent, + LONG: BinaryEditComponent, 'foreign key': ForeignKeyEditComponent, }, @@ -252,10 +254,10 @@ export const recordEditTypes = { smalldatetime: DateTimeEditComponent, timestamp: DateTimeEditComponent, - //file - binary: FileEditComponent, - varbinary: FileEditComponent, - image: FileEditComponent, + //binary + binary: BinaryEditComponent, + varbinary: BinaryEditComponent, + image: BinaryEditComponent, // etc money: MoneyEditComponent, @@ -283,8 +285,8 @@ export const recordEditTypes = { regexp: TextEditComponent, objectid: TextEditComponent, - //file - binary: FileEditComponent, + //binary + binary: BinaryEditComponent, //json object: JsonEditorEditComponent, @@ -302,7 +304,7 @@ export const recordEditTypes = { null: StaticTextEditComponent, array: JsonEditorEditComponent, json: JsonEditorEditComponent, - binary: FileEditComponent, + binary: BinaryEditComponent, }, cassandra: { int: NumberEditComponent, @@ -345,7 +347,7 @@ export const recordEditTypes = { date: DateEditComponent, object: JsonEditorEditComponent, array: JsonEditorEditComponent, - binary: FileEditComponent, + binary: BinaryEditComponent, }, clickhouse: { string: TextEditComponent, diff --git a/frontend/src/app/consts/record-view-types.ts b/frontend/src/app/consts/record-view-types.ts index 3e4375a80..4f4f6e142 100644 --- a/frontend/src/app/consts/record-view-types.ts +++ b/frontend/src/app/consts/record-view-types.ts @@ -1,6 +1,7 @@ import { BooleanRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/boolean/boolean.component'; import { LongTextRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/long-text/long-text.component'; import { TextRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/text/text.component'; +import { BinaryRecordViewComponent } from '../components/ui-components/record-view-fields/binary/binary.component'; import { CodeRecordViewComponent } from '../components/ui-components/record-view-fields/code/code.component'; import { ColorRecordViewComponent } from '../components/ui-components/record-view-fields/color/color.component'; import { CountryRecordViewComponent } from '../components/ui-components/record-view-fields/country/country.component'; @@ -43,6 +44,7 @@ export const UIwidgets = { Number: NumberRecordViewComponent, Select: SelectRecordViewComponent, Password: PasswordRecordViewComponent, + Binary: BinaryRecordViewComponent, File: FileRecordViewComponent, Code: CodeRecordViewComponent, Image: ImageRecordViewComponent, @@ -101,8 +103,8 @@ export const recordViewFieldTypes = { jsonb: JsonEditorRecordViewComponent, ARRAY: JsonEditorRecordViewComponent, - //file - bytea: FileRecordViewComponent, + //binary + bytea: BinaryRecordViewComponent, //etc money: MoneyRecordViewComponent, @@ -154,13 +156,13 @@ export const recordViewFieldTypes = { //select (select) enum: SelectRecordViewComponent, - //file - binary: FileRecordViewComponent, - varbinary: FileRecordViewComponent, - blob: FileRecordViewComponent, - tinyblob: FileRecordViewComponent, - mediumblob: FileRecordViewComponent, - longblob: FileRecordViewComponent, + //binary + binary: BinaryRecordViewComponent, + varbinary: BinaryRecordViewComponent, + blob: BinaryRecordViewComponent, + tinyblob: BinaryRecordViewComponent, + mediumblob: BinaryRecordViewComponent, + longblob: BinaryRecordViewComponent, //etc set: TextRecordViewComponent, @@ -190,12 +192,12 @@ export const recordViewFieldTypes = { VARCHAR: TextRecordViewComponent, NVARCHAR2: TextRecordViewComponent, - //file - BLOB: FileRecordViewComponent, - BFILE: FileRecordViewComponent, - RAW: FileRecordViewComponent, - 'LONG RAW': FileRecordViewComponent, - LONG: FileRecordViewComponent, + //binary + BLOB: BinaryRecordViewComponent, + BFILE: BinaryRecordViewComponent, + RAW: BinaryRecordViewComponent, + 'LONG RAW': BinaryRecordViewComponent, + LONG: BinaryRecordViewComponent, 'foreign key': ForeignKeyRecordViewComponent, }, @@ -228,9 +230,9 @@ export const recordViewFieldTypes = { smalldatetime: DateTimeRecordViewComponent, timestamp: DateTimeRecordViewComponent, - //file - binary: FileRecordViewComponent, - varbinary: FileRecordViewComponent, + //binary + binary: BinaryRecordViewComponent, + varbinary: BinaryRecordViewComponent, image: ImageRecordViewComponent, // etc @@ -259,8 +261,8 @@ export const recordViewFieldTypes = { regexp: TextRecordViewComponent, objectid: TextRecordViewComponent, - //file - binary: FileRecordViewComponent, + //binary + binary: BinaryRecordViewComponent, //json object: JsonEditorRecordViewComponent, @@ -278,7 +280,7 @@ export const recordViewFieldTypes = { null: StaticTextRecordViewComponent, array: JsonEditorRecordViewComponent, json: JsonEditorRecordViewComponent, - binary: FileRecordViewComponent, + binary: BinaryRecordViewComponent, }, cassandra: { int: NumberRecordViewComponent, @@ -321,7 +323,7 @@ export const recordViewFieldTypes = { date: DateRecordViewComponent, object: JsonEditorRecordViewComponent, array: JsonEditorRecordViewComponent, - binary: FileRecordViewComponent, + binary: BinaryRecordViewComponent, }, clickhouse: { string: TextRecordViewComponent, diff --git a/frontend/src/app/consts/table-display-types.ts b/frontend/src/app/consts/table-display-types.ts index 8e8c514dc..9808bded5 100644 --- a/frontend/src/app/consts/table-display-types.ts +++ b/frontend/src/app/consts/table-display-types.ts @@ -1,6 +1,7 @@ import { BooleanDisplayComponent } from 'src/app/components/ui-components/table-display-fields/boolean/boolean.component'; import { LongTextDisplayComponent } from 'src/app/components/ui-components/table-display-fields/long-text/long-text.component'; import { TextDisplayComponent } from 'src/app/components/ui-components/table-display-fields/text/text.component'; +import { BinaryDisplayComponent } from '../components/ui-components/table-display-fields/binary/binary.component'; import { CodeDisplayComponent } from '../components/ui-components/table-display-fields/code/code.component'; import { ColorDisplayComponent } from '../components/ui-components/table-display-fields/color/color.component'; import { CountryDisplayComponent } from '../components/ui-components/table-display-fields/country/country.component'; @@ -36,6 +37,7 @@ export const UIwidgets = { Country: CountryDisplayComponent, Date: DateDisplayComponent, DateTime: DateTimeDisplayComponent, + Binary: BinaryDisplayComponent, File: FileDisplayComponent, Foreign_key: ForeignKeyDisplayComponent, Image: ImageDisplayComponent, @@ -102,8 +104,8 @@ export const tableDisplayTypes = { jsonb: JsonEditorDisplayComponent, ARRAY: JsonEditorDisplayComponent, - //file - bytea: FileDisplayComponent, + //binary + bytea: BinaryDisplayComponent, //etc money: MoneyDisplayComponent, @@ -155,13 +157,13 @@ export const tableDisplayTypes = { //select (select) enum: SelectDisplayComponent, - //file - binary: FileDisplayComponent, - varbinary: FileDisplayComponent, - blob: FileDisplayComponent, - tinyblob: FileDisplayComponent, - mediumblob: FileDisplayComponent, - longblob: FileDisplayComponent, + //binary + binary: BinaryDisplayComponent, + varbinary: BinaryDisplayComponent, + blob: BinaryDisplayComponent, + tinyblob: BinaryDisplayComponent, + mediumblob: BinaryDisplayComponent, + longblob: BinaryDisplayComponent, //etc set: TextDisplayComponent, @@ -191,12 +193,12 @@ export const tableDisplayTypes = { VARCHAR: TextDisplayComponent, NVARCHAR2: TextDisplayComponent, - //file - BLOB: FileDisplayComponent, - BFILE: FileDisplayComponent, - RAW: FileDisplayComponent, - 'LONG RAW': FileDisplayComponent, - LONG: FileDisplayComponent, + //binary + BLOB: BinaryDisplayComponent, + BFILE: BinaryDisplayComponent, + RAW: BinaryDisplayComponent, + 'LONG RAW': BinaryDisplayComponent, + LONG: BinaryDisplayComponent, 'foreign key': ForeignKeyDisplayComponent, }, @@ -229,9 +231,9 @@ export const tableDisplayTypes = { smalldatetime: DateTimeDisplayComponent, timestamp: DateTimeDisplayComponent, - //file - binary: FileDisplayComponent, - varbinary: FileDisplayComponent, + //binary + binary: BinaryDisplayComponent, + varbinary: BinaryDisplayComponent, image: ImageDisplayComponent, // etc @@ -260,8 +262,8 @@ export const tableDisplayTypes = { regexp: TextDisplayComponent, objectid: TextDisplayComponent, - //file - binary: FileDisplayComponent, + //binary + binary: BinaryDisplayComponent, //json object: JsonEditorDisplayComponent, @@ -279,7 +281,7 @@ export const tableDisplayTypes = { null: StaticTextDisplayComponent, array: JsonEditorDisplayComponent, json: JsonEditorDisplayComponent, - binary: FileDisplayComponent, + binary: BinaryDisplayComponent, }, cassandra: { int: NumberDisplayComponent, @@ -322,7 +324,7 @@ export const tableDisplayTypes = { date: DateDisplayComponent, object: JsonEditorDisplayComponent, array: JsonEditorDisplayComponent, - binary: FileDisplayComponent, + binary: BinaryDisplayComponent, }, clickhouse: { string: TextDisplayComponent, diff --git a/frontend/src/app/lib/binary.spec.ts b/frontend/src/app/lib/binary.spec.ts new file mode 100644 index 000000000..2242b26f8 --- /dev/null +++ b/frontend/src/app/lib/binary.spec.ts @@ -0,0 +1,67 @@ +import { bytesToHex, hexStringToBytes, parseBinaryValue, stringToBytes, toBufferJson } from './binary'; + +describe('binary helpers', () => { + describe('parseBinaryValue', () => { + it('returns empty for null/undefined/empty', () => { + expect(parseBinaryValue(null)).toEqual([]); + expect(parseBinaryValue(undefined)).toEqual([]); + expect(parseBinaryValue('')).toEqual([]); + }); + + it('parses a string as char-code-per-byte', () => { + expect(parseBinaryValue('Hello')).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]); + }); + + it('truncates char codes above 0xff to a byte', () => { + expect(parseBinaryValue('\u00ff\u0100')).toEqual([0xff, 0x00]); + }); + + it('extracts data from a Buffer-JSON value', () => { + expect(parseBinaryValue({ type: 'Buffer', data: [0x48, 0x65] })).toEqual([0x48, 0x65]); + }); + + it('extracts data from a Uint8Array', () => { + expect(parseBinaryValue(new Uint8Array([1, 2, 3]))).toEqual([1, 2, 3]); + }); + }); + + describe('stringToBytes', () => { + it('maps each char to its 8-bit code', () => { + expect(stringToBytes('Hi')).toEqual([0x48, 0x69]); + }); + }); + + describe('hexStringToBytes', () => { + it('parses even-length hex', () => { + expect(hexStringToBytes('48656c6c6f')).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]); + }); + + it('left-pads odd-length hex', () => { + expect(hexStringToBytes('abc')).toEqual([0x0a, 0xbc]); + }); + + it('returns empty for malformed hex', () => { + expect(hexStringToBytes('zz')).toEqual([]); + }); + + it('returns empty for empty input', () => { + expect(hexStringToBytes('')).toEqual([]); + }); + }); + + describe('bytesToHex', () => { + it('formats bytes with zero-padded lowercase hex', () => { + expect(bytesToHex([0x01, 0xab, 0x00])).toBe('01ab00'); + }); + + it('returns empty string for empty array', () => { + expect(bytesToHex([])).toBe(''); + }); + }); + + describe('toBufferJson', () => { + it('wraps bytes in Buffer-JSON shape', () => { + expect(toBufferJson([1, 2])).toEqual({ type: 'Buffer', data: [1, 2] }); + }); + }); +}); diff --git a/frontend/src/app/lib/binary.ts b/frontend/src/app/lib/binary.ts new file mode 100644 index 000000000..2a1182645 --- /dev/null +++ b/frontend/src/app/lib/binary.ts @@ -0,0 +1,40 @@ +export type BinaryBufferJson = { type: 'Buffer'; data: number[] }; + +export function parseBinaryValue(value: unknown): number[] { + if (value == null || value === '') return []; + if (value instanceof Uint8Array) return Array.from(value); + if (typeof value === 'string') return stringToBytes(value); + if (typeof value === 'object') { + const data = (value as { data?: unknown }).data; + if (Array.isArray(data)) return (data as number[]).slice(); + } + return []; +} + +export function stringToBytes(str: string): number[] { + const bytes: number[] = []; + for (let i = 0; i < str.length; i++) { + bytes.push(str.charCodeAt(i) & 0xff); + } + return bytes; +} + +export function hexStringToBytes(hex: string): number[] { + if (!hex) return []; + const normalized = hex.length % 2 === 0 ? hex : `0${hex}`; + const bytes: number[] = []; + for (let i = 0; i < normalized.length; i += 2) { + const byte = parseInt(normalized.substring(i, i + 2), 16); + if (Number.isNaN(byte)) return []; + bytes.push(byte); + } + return bytes; +} + +export function bytesToHex(bytes: number[]): string { + return bytes.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +export function toBufferJson(bytes: number[]): BinaryBufferJson { + return { type: 'Buffer', data: bytes }; +} diff --git a/shared-code/src/shared/enums/table-widget-type.enum.ts b/shared-code/src/shared/enums/table-widget-type.enum.ts index ef58f18cd..8d5fe9c9a 100644 --- a/shared-code/src/shared/enums/table-widget-type.enum.ts +++ b/shared-code/src/shared/enums/table-widget-type.enum.ts @@ -23,4 +23,5 @@ export enum TableWidgetTypeEnum { Range = 'Range', Timezone = 'Timezone', S3 = 'S3', + Binary = 'Binary', }