apps/nestjs-backend/src/features/record/record.service.ts
/* eslint-disable @typescript-eslint/naming-convention */
import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import type {
IAttachmentCellValue,
IColumnMeta,
IExtraResult,
IFilter,
IFilterSet,
IGroup,
ILinkCellValue,
IRecord,
ISnapshotBase,
ISortItem,
} from '@teable/core';
import {
and,
CellFormat,
FieldKeyType,
FieldType,
generateRecordId,
identify,
IdPrefix,
mergeFilter,
mergeWithDefaultFilter,
mergeWithDefaultSort,
or,
parseGroup,
Relationship,
} from '@teable/core';
import type { Prisma } from '@teable/db-main-prisma';
import { PrismaService } from '@teable/db-main-prisma';
import type {
ICreateRecordsRo,
IGetRecordQuery,
IGetRecordsRo,
IGroupHeaderPoint,
IGroupPoint,
IGroupPointsVo,
IRecordStatusVo,
IRecordsVo,
} from '@teable/openapi';
import { GroupPointType, UploadType } from '@teable/openapi';
import { Knex } from 'knex';
import { get, difference, keyBy } from 'lodash';
import { InjectModel } from 'nest-knexjs';
import { ClsService } from 'nestjs-cls';
import { CacheService } from '../../cache/cache.service';
import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';
import { InjectDbProvider } from '../../db-provider/db.provider';
import { IDbProvider } from '../../db-provider/db.provider.interface';
import { RawOpType } from '../../share-db/interface';
import type { IClsStore } from '../../types/cls';
import { convertValueToStringify, string2Hash } from '../../utils';
import { generateFilterItem } from '../../utils/filter';
import {
generateTableThumbnailPath,
getTableThumbnailToken,
} from '../../utils/generate-table-thumbnail-path';
import { Timing } from '../../utils/timing';
import { AttachmentsStorageService } from '../attachments/attachments-storage.service';
import StorageAdapter from '../attachments/plugins/adapter';
import { BatchService } from '../calculation/batch.service';
import type { IVisualTableDefaultField } from '../field/constant';
import { preservedDbFieldNames } from '../field/constant';
import type { IFieldInstance } from '../field/model/factory';
import { createFieldInstanceByRaw } from '../field/model/factory';
import { ROW_ORDER_FIELD_PREFIX } from '../view/constant';
import { IFieldRaws } from './type';
type IUserFields = { id: string; dbFieldName: string }[];
function removeUndefined<T extends Record<string, unknown>>(obj: T) {
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;
}
export interface IRecordInnerRo {
id: string;
fields: Record<string, unknown>;
createdBy?: string;
lastModifiedBy?: string;
createdTime?: string;
lastModifiedTime?: string;
autoNumber?: number;
order?: Record<string, number>; // viewId: index
}
@Injectable()
export class RecordService {
private logger = new Logger(RecordService.name);
constructor(
private readonly prismaService: PrismaService,
private readonly batchService: BatchService,
private readonly cls: ClsService<IClsStore>,
private readonly cacheService: CacheService,
private readonly attachmentStorageService: AttachmentsStorageService,
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
@InjectDbProvider() private readonly dbProvider: IDbProvider,
@ThresholdConfig() private readonly thresholdConfig: IThresholdConfig
) {}
private dbRecord2RecordFields(
record: IRecord['fields'],
fields: IFieldInstance[],
fieldKeyType?: FieldKeyType,
cellFormat: CellFormat = CellFormat.Json
) {
return fields.reduce<IRecord['fields']>((acc, field) => {
const fieldNameOrId = fieldKeyType === FieldKeyType.Name ? field.name : field.id;
const dbCellValue = record[field.dbFieldName];
const cellValue = field.convertDBValue2CellValue(dbCellValue);
if (cellValue != null) {
acc[fieldNameOrId] =
cellFormat === CellFormat.Text ? field.cellValue2String(cellValue) : cellValue;
}
return acc;
}, {});
}
async getAllRecordCount(dbTableName: string) {
const sqlNative = this.knex(dbTableName).count({ count: '*' }).toSQL().toNative();
const queryResult = await this.prismaService
.txClient()
.$queryRawUnsafe<{ count?: number }[]>(sqlNative.sql, ...sqlNative.bindings);
return Number(queryResult[0]?.count ?? 0);
}
async getDbValueMatrix(
dbTableName: string,
userFields: IUserFields,
rowIndexFieldNames: string[],
createRecordsRo: ICreateRecordsRo
) {
const rowCount = await this.getAllRecordCount(dbTableName);
const dbValueMatrix: unknown[][] = [];
for (let i = 0; i < createRecordsRo.records.length; i++) {
const recordData = createRecordsRo.records[i].fields;
// 1. collect cellValues
const recordValues = userFields.map<unknown>((field) => {
const cellValue = recordData[field.id];
if (cellValue == null) {
return null;
}
return cellValue;
});
// 2. generate rowIndexValues
const rowIndexValues = rowIndexFieldNames.map(() => rowCount + i);
// 3. generate id, __created_time, __created_by, __version
const systemValues = [generateRecordId(), new Date().toISOString(), 'admin', 1];
dbValueMatrix.push([...recordValues, ...rowIndexValues, ...systemValues]);
}
return dbValueMatrix;
}
async getDbTableName(tableId: string) {
const tableMeta = await this.prismaService
.txClient()
.tableMeta.findUniqueOrThrow({
where: { id: tableId },
select: { dbTableName: true },
})
.catch(() => {
throw new NotFoundException(`Table ${tableId} not found`);
});
return tableMeta.dbTableName;
}
private async getLinkCellIds(tableId: string, field: IFieldInstance, recordId: string) {
const prisma = this.prismaService.txClient();
const dbTableName = await prisma.tableMeta.findFirstOrThrow({
where: { id: tableId },
select: { dbTableName: true },
});
const linkCellQuery = this.knex(dbTableName)
.select({
id: '__id',
linkField: field.dbFieldName,
})
.where('__id', recordId)
.toQuery();
const result = await prisma.$queryRawUnsafe<
{
id: string;
linkField: string | null;
}[]
>(linkCellQuery);
return result
.map(
(item) =>
field.convertDBValue2CellValue(item.linkField) as ILinkCellValue | ILinkCellValue[]
)
.filter(Boolean)
.flat()
.map((item) => item.id);
}
private async buildLinkSelectedSort(
queryBuilder: Knex.QueryBuilder,
dbTableName: string,
filterLinkCellSelected: [string, string]
) {
const prisma = this.prismaService.txClient();
const [fieldId, recordId] = filterLinkCellSelected;
const fieldRaw = await prisma.field
.findFirstOrThrow({
where: { id: fieldId, deletedTime: null },
})
.catch(() => {
throw new NotFoundException(`Field ${fieldId} not found`);
});
const field = createFieldInstanceByRaw(fieldRaw);
if (!field.isMultipleCellValue) {
return;
}
const ids = await this.getLinkCellIds(fieldRaw.tableId, field, recordId);
if (!ids.length) {
return;
}
// sql capable for sqlite
const valuesQuery = ids
.map((id, index) => `SELECT ${index + 1} AS sort_order, '${id}' AS id`)
.join(' UNION ALL ');
queryBuilder
.with('ordered_ids', this.knex.raw(`${valuesQuery}`))
.leftJoin('ordered_ids', function () {
this.on(`${dbTableName}.__id`, '=', 'ordered_ids.id');
})
.orderBy('ordered_ids.sort_order');
}
private isJunctionTable(dbTableName: string) {
if (dbTableName.includes('.')) {
return dbTableName.split('.')[1].startsWith('junction');
}
return dbTableName.split('_')[1].startsWith('junction');
}
// eslint-disable-next-line sonarjs/cognitive-complexity
async buildLinkSelectedQuery(
queryBuilder: Knex.QueryBuilder,
tableId: string,
dbTableName: string,
filterLinkCellSelected: [string, string] | string
) {
const prisma = this.prismaService.txClient();
const fieldId = Array.isArray(filterLinkCellSelected)
? filterLinkCellSelected[0]
: filterLinkCellSelected;
const recordId = Array.isArray(filterLinkCellSelected) ? filterLinkCellSelected[1] : undefined;
const fieldRaw = await prisma.field
.findFirstOrThrow({
where: { id: fieldId, deletedTime: null },
})
.catch(() => {
throw new NotFoundException(`Field ${fieldId} not found`);
});
const field = createFieldInstanceByRaw(fieldRaw);
if (field.type !== FieldType.Link) {
throw new BadRequestException('You can only filter by link field');
}
const { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName } = field.options;
if (foreignTableId !== tableId) {
throw new BadRequestException('Field is not linked to current table');
}
if (fkHostTableName !== dbTableName) {
queryBuilder.leftJoin(
`${fkHostTableName}`,
`${dbTableName}.__id`,
'=',
`${fkHostTableName}.${foreignKeyName}`
);
if (recordId) {
queryBuilder.where(`${fkHostTableName}.${selfKeyName}`, recordId);
return;
}
queryBuilder.whereNotNull(`${fkHostTableName}.${foreignKeyName}`);
return;
}
if (recordId) {
queryBuilder.where(`${dbTableName}.${selfKeyName}`, recordId);
return;
}
queryBuilder.whereNotNull(`${dbTableName}.${selfKeyName}`);
}
async buildLinkCandidateQuery(
queryBuilder: Knex.QueryBuilder,
tableId: string,
dbTableName: string,
filterLinkCellCandidate: [string, string] | string
) {
const prisma = this.prismaService.txClient();
const fieldId = Array.isArray(filterLinkCellCandidate)
? filterLinkCellCandidate[0]
: filterLinkCellCandidate;
const recordId = Array.isArray(filterLinkCellCandidate)
? filterLinkCellCandidate[1]
: undefined;
const fieldRaw = await prisma.field
.findFirstOrThrow({
where: { id: fieldId, deletedTime: null },
})
.catch(() => {
throw new NotFoundException(`Field ${fieldId} not found`);
});
const field = createFieldInstanceByRaw(fieldRaw);
if (field.type !== FieldType.Link) {
throw new BadRequestException('You can only filter by link field');
}
const { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName, relationship } =
field.options;
if (foreignTableId !== tableId) {
throw new BadRequestException('Field is not linked to current table');
}
if (relationship === Relationship.OneMany) {
if (this.isJunctionTable(fkHostTableName)) {
queryBuilder.whereNotIn('__id', function () {
this.select(foreignKeyName).from(fkHostTableName);
});
} else {
queryBuilder.where(selfKeyName, null);
}
}
if (relationship === Relationship.OneOne) {
if (selfKeyName === '__id') {
queryBuilder.whereNotIn('__id', function () {
this.select(foreignKeyName).from(fkHostTableName).whereNotNull(foreignKeyName);
});
} else {
queryBuilder.where(selfKeyName, null);
}
}
if (recordId) {
const linkIds = await this.getLinkCellIds(fieldRaw.tableId, field, recordId);
if (linkIds.length) {
queryBuilder.whereNotIn('__id', linkIds);
}
}
}
private async getNecessaryFieldMap(
tableId: string,
filter?: IFilter,
orderBy?: ISortItem[],
groupBy?: IGroup,
search?: string[]
) {
if (filter || orderBy?.length || groupBy?.length || search) {
// The field Meta is needed to construct the filter if it exists
const fields = await this.getFieldsByProjection(tableId);
return fields.reduce(
(map, field) => {
map[field.id] = field;
map[field.name] = field;
return map;
},
{} as Record<string, IFieldInstance>
);
}
}
private getFieldMapWithoutHiddenFields(
originFieldMap?: Record<string, IFieldInstance>,
columnMetaRaw?: string
) {
if (!columnMetaRaw || !originFieldMap) {
return originFieldMap;
}
const newFieldMap = { ...originFieldMap };
const parseColumnMeta = JSON.parse(columnMetaRaw);
if (parseColumnMeta) {
Object.entries(parseColumnMeta).forEach(([key, value]) => {
const hidden = get(value, 'hidden');
hidden && delete newFieldMap[key];
});
}
return newFieldMap as Record<string, IFieldInstance>;
}
private async getTinyView(tableId: string, viewId?: string) {
if (!viewId) {
return;
}
return this.prismaService
.txClient()
.view.findFirstOrThrow({
select: { id: true, type: true, filter: true, sort: true, group: true, columnMeta: true },
where: { tableId, id: viewId, deletedTime: null },
})
.catch(() => {
throw new NotFoundException(`View ${viewId} not found`);
});
}
private parseSearch(search: string[], fieldMap?: Record<string, IFieldInstance>) {
const [searchValue, fieldIdOrName] = search;
if (!fieldMap) {
throw new Error('fieldMap is required when search is set');
}
if (!fieldIdOrName) {
return [
searchValue,
Object.values(fieldMap)
.map((f) => f.id)
.join(','),
];
}
const fieldIds = fieldIdOrName.split(',');
fieldIds.forEach((id) => {
const field = fieldMap[id];
if (!field) {
throw new NotFoundException(`Field ${id} not found`);
}
});
return [searchValue, fieldIdOrName];
}
async prepareQuery(
tableId: string,
query: Pick<IGetRecordsRo, 'viewId' | 'orderBy' | 'groupBy' | 'filter' | 'search'>
) {
const {
viewId,
orderBy: extraOrderBy,
groupBy: extraGroupBy,
filter: extraFilter,
search: originSearch,
} = query;
const dbTableName = await this.getDbTableName(tableId);
const queryBuilder = this.knex(dbTableName);
const view = await this.getTinyView(tableId, viewId);
const filter = mergeWithDefaultFilter(view?.filter, extraFilter);
const orderBy = mergeWithDefaultSort(view?.sort, extraOrderBy);
const groupBy = parseGroup(extraGroupBy);
const fieldMap = await this.getNecessaryFieldMap(
tableId,
filter,
orderBy,
groupBy,
originSearch
);
const fieldMapWithoutHiddenFields = this.getFieldMapWithoutHiddenFields(
fieldMap,
view?.columnMeta
);
const search = originSearch
? this.parseSearch(originSearch, fieldMapWithoutHiddenFields)
: undefined;
return {
queryBuilder,
dbTableName,
filter,
search,
orderBy,
groupBy,
fieldMap,
fieldMapWithoutHiddenFields,
};
}
async getBasicOrderIndexField(dbTableName: string, viewId: string | undefined) {
const columnName = `${ROW_ORDER_FIELD_PREFIX}_${viewId}`;
const exists = await this.dbProvider.checkColumnExist(
dbTableName,
columnName,
this.prismaService.txClient()
);
if (exists) {
return columnName;
}
return '__auto_number';
}
/**
* Builds a query based on filtering and sorting criteria.
*
* This method creates a `Knex` query builder that constructs SQL queries based on the provided
* filtering and sorting parameters. It also takes into account the context of the current user,
* which is crucial for ensuring the security and relevance of data access.
*
* @param {string} tableId - The unique identifier of the table to determine the target of the query.
* @param {Pick<IGetRecordsRo, 'viewId' | 'orderBy' | 'filter' | 'filterLinkCellCandidate'>} query - An object of query parameters, including view ID, sorting rules, filtering conditions, etc.
* @returns {Promise<Knex.QueryBuilder>} Returns an instance of the Knex query builder encapsulating the constructed SQL query.
*/
async buildFilterSortQuery(
tableId: string,
query: Pick<
IGetRecordsRo,
| 'viewId'
| 'orderBy'
| 'groupBy'
| 'filter'
| 'search'
| 'filterLinkCellCandidate'
| 'filterLinkCellSelected'
| 'collapsedGroupIds'
| 'selectedRecordIds'
>
): Promise<Knex.QueryBuilder> {
// Prepare the base query builder, filtering conditions, sorting rules, grouping rules and field mapping
const {
dbTableName,
queryBuilder,
filter,
search,
orderBy,
groupBy,
fieldMap,
fieldMapWithoutHiddenFields,
} = await this.prepareQuery(tableId, query);
// Retrieve the current user's ID to build user-related query conditions
const currentUserId = this.cls.get('user.id');
if (query.filterLinkCellSelected && query.filterLinkCellCandidate) {
throw new BadRequestException(
'filterLinkCellSelected and filterLinkCellCandidate can not be set at the same time'
);
}
if (query.selectedRecordIds) {
query.filterLinkCellCandidate
? queryBuilder.whereNotIn(`${dbTableName}.__id`, query.selectedRecordIds)
: queryBuilder.whereIn(`${dbTableName}.__id`, query.selectedRecordIds);
}
if (query.filterLinkCellCandidate) {
await this.buildLinkCandidateQuery(
queryBuilder,
tableId,
dbTableName,
query.filterLinkCellCandidate
);
}
if (query.filterLinkCellSelected) {
await this.buildLinkSelectedQuery(
queryBuilder,
tableId,
dbTableName,
query.filterLinkCellSelected
);
}
// Add filtering conditions to the query builder
this.dbProvider
.filterQuery(queryBuilder, fieldMap, filter, { withUserId: currentUserId })
.appendQueryBuilder();
// Add sorting rules to the query builder
this.dbProvider
.sortQuery(queryBuilder, fieldMap, [...(groupBy ?? []), ...orderBy])
.appendSortBuilder();
queryBuilder.where((builder) => {
this.dbProvider.searchQuery(builder, fieldMapWithoutHiddenFields, search);
});
// ignore sorting when filterLinkCellSelected is set
if (query.filterLinkCellSelected && Array.isArray(query.filterLinkCellSelected)) {
await this.buildLinkSelectedSort(queryBuilder, dbTableName, query.filterLinkCellSelected);
} else {
const basicSortIndex = await this.getBasicOrderIndexField(dbTableName, query.viewId);
// view sorting added by default
queryBuilder.orderBy(`${dbTableName}.${basicSortIndex}`, 'asc');
}
this.logger.debug('buildFilterSortQuery: %s', queryBuilder.toQuery());
// If you return `queryBuilder` directly and use `await` to receive it,
// it will perform a query DB operation, which we obviously don't want to see here
return { queryBuilder, dbTableName };
}
convertProjection(fieldKeys?: string[]) {
return fieldKeys?.reduce<Record<string, boolean>>((acc, cur) => {
acc[cur] = true;
return acc;
}, {});
}
async getRecordsById(tableId: string, recordIds: string[]): Promise<IRecordsVo> {
const recordSnapshot = await this.getSnapshotBulk(
tableId,
recordIds,
undefined,
FieldKeyType.Id
);
if (!recordSnapshot.length) {
throw new NotFoundException('Can not get records');
}
return {
records: recordSnapshot.map((r) => r.data),
};
}
private async getViewProjection(
tableId: string,
query: IGetRecordsRo
): Promise<Record<string, boolean> | undefined> {
const viewId = query.viewId;
if (!viewId) {
return;
}
const fieldKeyType = query.fieldKeyType || FieldKeyType.Name;
const view = await this.prismaService.txClient().view.findFirstOrThrow({
where: { id: viewId, deletedTime: null },
select: { id: true, columnMeta: true },
});
const columnMeta = JSON.parse(view.columnMeta) as IColumnMeta;
const useVisible = Object.values(columnMeta).some((column) => 'visible' in column);
const useHidden = Object.values(columnMeta).some((column) => 'hidden' in column);
if (!useVisible && !useHidden) {
return;
}
const fieldIdOrNames = await this.prismaService.txClient().field.findMany({
where: { tableId, deletedTime: null },
select: { id: true, name: true },
});
const fieldMap = keyBy(fieldIdOrNames, 'id');
const projection = Object.entries(columnMeta).reduce<Record<string, boolean>>(
(acc, [fieldId, column]) => {
const field = fieldMap[fieldId];
if (!field) return acc;
const fieldKey = fieldKeyType === FieldKeyType.Id ? field.id : field.name;
if (useVisible) {
if ('visible' in column && column.visible) {
acc[fieldKey] = true;
}
} else if (useHidden) {
if (!('hidden' in column) || !column.hidden) {
acc[fieldKey] = true;
}
} else {
acc[fieldKey] = true;
}
return acc;
},
{}
);
return Object.keys(projection).length > 0 ? projection : undefined;
}
async getRecords(tableId: string, query: IGetRecordsRo): Promise<IRecordsVo> {
const queryResult = await this.getDocIdsByQuery(tableId, {
viewId: query.viewId,
skip: query.skip,
take: query.take,
filter: query.filter,
orderBy: query.orderBy,
search: query.search,
groupBy: query.groupBy,
filterLinkCellCandidate: query.filterLinkCellCandidate,
filterLinkCellSelected: query.filterLinkCellSelected,
selectedRecordIds: query.selectedRecordIds,
});
const projection = query.projection
? this.convertProjection(query.projection)
: await this.getViewProjection(tableId, query);
const recordSnapshot = await this.getSnapshotBulk(
tableId,
queryResult.ids,
projection,
query.fieldKeyType || FieldKeyType.Name,
query.cellFormat
);
return {
records: recordSnapshot.map((r) => r.data),
extra: queryResult.extra,
};
}
async getRecord(tableId: string, recordId: string, query: IGetRecordQuery): Promise<IRecord> {
const { projection, fieldKeyType = FieldKeyType.Name, cellFormat } = query;
const recordSnapshot = await this.getSnapshotBulk(
tableId,
[recordId],
this.convertProjection(projection),
fieldKeyType,
cellFormat
);
if (!recordSnapshot.length) {
throw new NotFoundException('Can not get record');
}
return recordSnapshot[0].data;
}
async getCellValue(tableId: string, recordId: string, fieldId: string) {
const record = await this.getRecord(tableId, recordId, {
projection: [fieldId],
fieldKeyType: FieldKeyType.Id,
});
return record.fields[fieldId];
}
async getMaxRecordOrder(dbTableName: string) {
const sqlNative = this.knex(dbTableName).max('__auto_number', { as: 'max' }).toSQL().toNative();
const result = await this.prismaService
.txClient()
.$queryRawUnsafe<{ max?: number }[]>(sqlNative.sql, ...sqlNative.bindings);
return Number(result[0]?.max ?? 0) + 1;
}
async batchDeleteRecords(tableId: string, recordIds: string[]) {
const dbTableName = await this.getDbTableName(tableId);
// get version by recordIds, __id as id, __version as version
const nativeQuery = this.knex(dbTableName)
.select('__id as id', '__version as version')
.whereIn('__id', recordIds)
.toQuery();
const recordRaw = await this.prismaService
.txClient()
.$queryRawUnsafe<{ id: string; version: number }[]>(nativeQuery);
if (recordIds.length !== recordRaw.length) {
throw new BadRequestException('delete record not found');
}
const recordRawMap = keyBy(recordRaw, 'id');
const dataList = recordIds.map((recordId) => ({
docId: recordId,
version: recordRawMap[recordId].version,
}));
await this.batchService.saveRawOps(tableId, RawOpType.Del, IdPrefix.Record, dataList);
await this.batchDel(tableId, recordIds);
}
private async getViewIndexColumns(dbTableName: string) {
const columnInfoQuery = this.dbProvider.columnInfo(dbTableName);
const columns = await this.prismaService
.txClient()
.$queryRawUnsafe<{ name: string }[]>(columnInfoQuery);
return columns
.filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX))
.map((column) => column.name);
}
async getRecordIndexes(
tableId: string,
recordIds: string[],
viewId?: string
): Promise<Record<string, number>[] | undefined> {
const dbTableName = await this.getDbTableName(tableId);
const allViewIndexColumns = await this.getViewIndexColumns(dbTableName);
const viewIndexColumns = viewId
? (() => {
const viewIndexColumns = allViewIndexColumns.filter((column) => column.endsWith(viewId));
return viewIndexColumns.length === 0 ? ['__auto_number'] : viewIndexColumns;
})()
: allViewIndexColumns;
if (!viewIndexColumns.length) {
return;
}
// get all viewIndexColumns value for __id in recordIds
const indexQuery = this.knex(dbTableName)
.select(
viewIndexColumns.reduce<Record<string, string>>((acc, columnName) => {
if (columnName === '__auto_number') {
acc[viewId as string] = '__auto_number';
return acc;
}
const theViewId = columnName.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
acc[theViewId] = columnName;
return acc;
}, {})
)
.select('__id')
.whereIn('__id', recordIds)
.toQuery();
const indexValues = await this.prismaService
.txClient()
.$queryRawUnsafe<Record<string, number>[]>(indexQuery);
const indexMap = indexValues.reduce<Record<string, Record<string, number>>>((map, cur) => {
const id = cur.__id;
delete cur.__id;
map[id] = cur;
return map;
}, {});
return recordIds.map((recordId) => indexMap[recordId]);
}
async updateRecordIndexes(
tableId: string,
recordsWithOrder: {
id: string;
order?: Record<string, number>;
}[]
) {
const dbTableName = await this.getDbTableName(tableId);
const viewIndexColumns = await this.getViewIndexColumns(dbTableName);
if (!viewIndexColumns.length) {
return;
}
const updateRecordSqls = recordsWithOrder
.map((record) => {
const order = record.order;
const orderFields = viewIndexColumns.reduce<Record<string, number>>((acc, columnName) => {
const viewId = columnName.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
const index = order?.[viewId];
if (index != null) {
acc[columnName] = index;
}
return acc;
}, {});
if (!order || Object.keys(orderFields).length === 0) {
return;
}
return this.knex(dbTableName).update(orderFields).where('__id', record.id).toQuery();
})
.filter(Boolean) as string[];
for (const sql of updateRecordSqls) {
await this.prismaService.txClient().$executeRawUnsafe(sql);
}
}
@Timing()
async batchCreateRecords(
tableId: string,
records: IRecordInnerRo[],
fieldKeyType: FieldKeyType,
fieldRaws: IFieldRaws
) {
const snapshots = await this.createBatch(tableId, records, fieldKeyType, fieldRaws);
const dataList = snapshots.map((snapshot) => ({
docId: snapshot.__id,
version: snapshot.__version == null ? 0 : snapshot.__version - 1,
}));
await this.batchService.saveRawOps(tableId, RawOpType.Create, IdPrefix.Record, dataList);
}
@Timing()
async createRecordsOnlySql(
tableId: string,
records: {
fields: Record<string, unknown>;
}[]
) {
const userId = this.cls.get('user.id');
await this.creditCheck(tableId);
const dbTableName = await this.getDbTableName(tableId);
const fields = await this.getFieldsByProjection(tableId);
const fieldInstanceMap = fields.reduce(
(map, curField) => {
map[curField.id] = curField;
return map;
},
{} as Record<string, IFieldInstance>
);
const newRecords = records.map((record) => {
const fieldsValues: Record<string, unknown> = {};
Object.entries(record.fields).forEach(([fieldId, value]) => {
const fieldInstance = fieldInstanceMap[fieldId];
fieldsValues[fieldInstance.dbFieldName] = fieldInstance.convertCellValue2DBValue(value);
});
return {
__id: generateRecordId(),
__created_by: userId,
__version: 1,
...fieldsValues,
};
});
const sql = this.dbProvider.batchInsertSql(dbTableName, newRecords);
await this.prismaService.txClient().$executeRawUnsafe(sql);
}
async creditCheck(tableId: string) {
if (!this.thresholdConfig.maxFreeRowLimit) {
return;
}
const table = await this.prismaService.txClient().tableMeta.findFirstOrThrow({
where: { id: tableId, deletedTime: null },
select: { dbTableName: true, base: { select: { space: { select: { credit: true } } } } },
});
const rowCount = await this.getAllRecordCount(table.dbTableName);
const maxRowCount =
table.base.space.credit == null
? this.thresholdConfig.maxFreeRowLimit
: table.base.space.credit;
if (rowCount >= maxRowCount) {
this.logger.log(`Exceed row count: ${maxRowCount}`, 'creditCheck');
throw new BadRequestException(
`Exceed max row limit: ${maxRowCount}, please contact us to increase the limit`
);
}
}
private async getAllViewIndexesField(dbTableName: string) {
const query = this.dbProvider.columnInfo(dbTableName);
const columns = await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(query);
return columns
.filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX))
.map((column) => column.name)
.reduce<{ [viewId: string]: string }>((acc, cur) => {
const viewId = cur.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
acc[viewId] = cur;
return acc;
}, {});
}
private async createBatch(
tableId: string,
records: IRecordInnerRo[],
fieldKeyType: FieldKeyType,
fieldRaws: IFieldRaws
) {
const userId = this.cls.get('user.id');
await this.creditCheck(tableId);
const dbTableName = await this.getDbTableName(tableId);
const maxRecordOrder = await this.getMaxRecordOrder(dbTableName);
const views = await this.prismaService.txClient().view.findMany({
where: { tableId, deletedTime: null },
select: { id: true },
});
const allViewIndexes = await this.getAllViewIndexesField(dbTableName);
const validationFields = fieldRaws.filter((field) => field.notNull || field.unique);
const snapshots = records
.map((record, i) =>
views.reduce<{ [viewIndexFieldName: string]: number }>((pre, cur) => {
const viewIndexFieldName = allViewIndexes[cur.id];
const recordViewIndex = record.order?.[cur.id];
if (!viewIndexFieldName) {
return pre;
}
if (recordViewIndex) {
pre[viewIndexFieldName] = recordViewIndex;
} else {
pre[viewIndexFieldName] = maxRecordOrder + i;
}
return pre;
}, {})
)
.map((order, i) => {
const snapshot = records[i];
const fields = snapshot.fields;
const dbFieldValueMap = validationFields.reduce(
(map, field) => {
const dbFieldName = field.dbFieldName;
const fieldKey = field[fieldKeyType];
const cellValue = fields[fieldKey];
map[dbFieldName] = cellValue;
return map;
},
{} as Record<string, unknown>
);
return removeUndefined({
__id: snapshot.id,
__created_by: snapshot.createdBy || userId,
__last_modified_by: snapshot.lastModifiedBy || undefined,
__created_time: snapshot.createdTime || undefined,
__last_modified_time: snapshot.lastModifiedTime || undefined,
__auto_number: snapshot.autoNumber == null ? undefined : snapshot.autoNumber,
__version: 1,
...order,
...dbFieldValueMap,
});
});
const sql = this.dbProvider.batchInsertSql(dbTableName, snapshots);
await this.prismaService.txClient().$executeRawUnsafe(sql);
return snapshots;
}
private async batchDel(tableId: string, recordIds: string[]) {
const dbTableName = await this.getDbTableName(tableId);
const nativeQuery = this.knex(dbTableName).whereIn('__id', recordIds).del().toQuery();
await this.prismaService.txClient().$executeRawUnsafe(nativeQuery);
}
private async getFieldsByProjection(
tableId: string,
projection?: { [fieldNameOrId: string]: boolean },
fieldKeyType: FieldKeyType = FieldKeyType.Id
) {
const whereParams: Prisma.FieldWhereInput = {};
if (projection) {
const projectionFieldKeys = Object.entries(projection)
.filter(([, v]) => v)
.map(([k]) => k);
if (projectionFieldKeys.length) {
const key = fieldKeyType === FieldKeyType.Id ? 'id' : 'name';
whereParams[key] = { in: projectionFieldKeys };
}
}
const fields = await this.prismaService.txClient().field.findMany({
where: { tableId, ...whereParams, deletedTime: null },
});
return fields.map((field) => createFieldInstanceByRaw(field));
}
private async getPreviewUrlTokenMap(
records: ISnapshotBase<IRecord>[],
fields: IFieldInstance[],
fieldKeyType: FieldKeyType
) {
const previewToken: string[] = [];
for (const field of fields) {
if (field.type === FieldType.Attachment) {
const fieldKey = fieldKeyType === FieldKeyType.Id ? field.id : field.name;
for (const record of records) {
const cellValue = record.data.fields[fieldKey];
if (cellValue == null) continue;
(cellValue as IAttachmentCellValue).forEach((item) => {
if (item.mimetype.startsWith('image/') && item.width && item.height) {
const { smThumbnailPath, lgThumbnailPath } = generateTableThumbnailPath(item.path);
previewToken.push(getTableThumbnailToken(smThumbnailPath));
previewToken.push(getTableThumbnailToken(lgThumbnailPath));
}
previewToken.push(item.token);
});
}
}
}
// limit 1000 one handle
const tokenMap: Record<string, string> = {};
for (let i = 0; i < previewToken.length; i += 1000) {
const tokenBatch = previewToken.slice(i, i + 1000);
const previewUrls = await this.cacheService.getMany(
tokenBatch.map((token) => `attachment:preview:${token}` as const)
);
previewUrls.forEach((url, index) => {
if (url) {
tokenMap[previewToken[i + index]] = url.url;
}
});
}
return tokenMap;
}
@Timing()
private async recordsPresignedUrl(
records: ISnapshotBase<IRecord>[],
fields: IFieldInstance[],
fieldKeyType: FieldKeyType
) {
const tokenUrlMap = await this.getPreviewUrlTokenMap(records, fields, fieldKeyType);
for (const field of fields) {
if (field.type === FieldType.Attachment) {
const fieldKey = fieldKeyType === FieldKeyType.Id ? field.id : field.name;
for (const record of records) {
const cellValue = record.data.fields[fieldKey];
const presignedCellValue = await this.getAttachmentPresignedCellValue(
cellValue as IAttachmentCellValue,
tokenUrlMap
);
if (presignedCellValue == null) continue;
record.data.fields[fieldKey] = presignedCellValue;
}
}
}
return records;
}
async getAttachmentPresignedCellValue(
cellValue: IAttachmentCellValue | null,
tokenUrlMap?: Record<string, string>
) {
if (cellValue == null) {
return null;
}
return await Promise.all(
cellValue.map(async (item) => {
const { path, mimetype, token, width, height } = item;
const presignedUrl =
tokenUrlMap?.[token] ??
(await this.attachmentStorageService.getPreviewUrlByPath(
StorageAdapter.getBucket(UploadType.Table),
path,
token,
undefined,
{
'Content-Type': mimetype,
'Content-Disposition': `attachment; filename="${item.name}"`,
}
));
if (width && height && mimetype.startsWith('image/')) {
const { smThumbnailPath, lgThumbnailPath } = generateTableThumbnailPath(path);
const smThumbnailToken = getTableThumbnailToken(smThumbnailPath);
const lgThumbnailToken = getTableThumbnailToken(lgThumbnailPath);
const selected: ('sm' | 'lg')[] = [];
const cacheSmThumbnailUrl = tokenUrlMap?.[smThumbnailToken];
const cacheLgThumbnailUrl = tokenUrlMap?.[lgThumbnailToken];
if (!cacheSmThumbnailUrl) {
selected.push('sm');
}
if (!cacheLgThumbnailUrl) {
selected.push('lg');
}
const { smThumbnailUrl, lgThumbnailUrl } =
await this.attachmentStorageService.getTableAttachmentThumbnailUrl(path, selected);
return {
...item,
smThumbnailUrl: cacheSmThumbnailUrl ?? smThumbnailUrl,
lgThumbnailUrl: cacheLgThumbnailUrl ?? lgThumbnailUrl,
presignedUrl,
};
}
return {
...item,
presignedUrl,
};
})
);
}
async getSnapshotBulk(
tableId: string,
recordIds: string[],
projection?: { [fieldNameOrId: string]: boolean },
fieldKeyType: FieldKeyType = FieldKeyType.Id, // for convince of collaboration, getSnapshotBulk use id as field key by default.
cellFormat = CellFormat.Json
): Promise<ISnapshotBase<IRecord>[]> {
const dbTableName = await this.getDbTableName(tableId);
const fields = await this.getFieldsByProjection(tableId, projection, fieldKeyType);
const fieldNames = fields.map((f) => f.dbFieldName).concat(Array.from(preservedDbFieldNames));
const nativeQuery = this.knex(dbTableName)
.select(fieldNames)
.whereIn('__id', recordIds)
.toQuery();
const result = await this.prismaService
.txClient()
.$queryRawUnsafe<
({ [fieldName: string]: unknown } & IVisualTableDefaultField)[]
>(nativeQuery);
const recordIdsMap = recordIds.reduce(
(acc, recordId, currentIndex) => {
acc[recordId] = currentIndex;
return acc;
},
{} as { [recordId: string]: number }
);
recordIds.forEach((recordId) => {
if (!(recordId in recordIdsMap)) {
throw new NotFoundException(`Record ${recordId} not found`);
}
});
const primaryFieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({
where: { tableId, isPrimary: true, deletedTime: null },
});
const primaryField = createFieldInstanceByRaw(primaryFieldRaw);
const snapshots = result
.sort((a, b) => {
return recordIdsMap[a.__id] - recordIdsMap[b.__id];
})
.map((record) => {
const recordFields = this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat);
const name = recordFields[primaryField[fieldKeyType]];
return {
id: record.__id,
v: record.__version,
type: 'json0',
data: {
fields: recordFields,
name:
cellFormat === CellFormat.Text
? (name as string)
: primaryField.cellValue2String(name),
id: record.__id,
autoNumber: record.__auto_number,
createdTime: record.__created_time?.toISOString(),
lastModifiedTime: record.__last_modified_time?.toISOString(),
createdBy: record.__created_by,
lastModifiedBy: record.__last_modified_by || undefined,
},
};
});
if (cellFormat === CellFormat.Json) {
return await this.recordsPresignedUrl(snapshots, fields, fieldKeyType);
}
return snapshots;
}
async getDocIdsByQuery(
tableId: string,
query: IGetRecordsRo
): Promise<{ ids: string[]; extra?: IExtraResult }> {
const { skip, take = 100 } = query;
if (identify(tableId) !== IdPrefix.Table) {
throw new InternalServerErrorException('query collection must be table id');
}
if (take > 1000) {
throw new BadRequestException(`limit can't be greater than ${take}`);
}
const { groupPoints, filter: filterWithGroup } = await this.getGroupRelatedData(tableId, query);
const { queryBuilder, dbTableName } = await this.buildFilterSortQuery(tableId, {
...query,
filter: filterWithGroup,
});
queryBuilder.select(this.knex.ref(`${dbTableName}.__id`));
queryBuilder.offset(skip);
if (take !== -1) {
queryBuilder.limit(take);
}
this.logger.debug('getRecordsQuery: %s', queryBuilder.toQuery());
const result = await this.prismaService
.txClient()
.$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
const ids = result.map((r) => r.__id);
return { ids, extra: { groupPoints } };
}
async getRecordsFields(
tableId: string,
query: IGetRecordsRo
): Promise<Pick<IRecord, 'id' | 'fields'>[]> {
if (identify(tableId) !== IdPrefix.Table) {
throw new InternalServerErrorException('query collection must be table id');
}
const {
skip,
take,
orderBy,
search,
groupBy,
collapsedGroupIds,
fieldKeyType,
cellFormat,
projection,
viewId,
filterLinkCellCandidate,
filterLinkCellSelected,
} = query;
const fields = await this.getFieldsByProjection(
tableId,
this.convertProjection(projection),
fieldKeyType
);
const fieldNames = fields.map((f) => f.dbFieldName);
const { filter: filterWithGroup } = await this.getGroupRelatedData(tableId, query);
const { queryBuilder } = await this.buildFilterSortQuery(tableId, {
viewId,
filter: filterWithGroup,
orderBy,
search,
groupBy,
collapsedGroupIds,
filterLinkCellCandidate,
filterLinkCellSelected,
});
queryBuilder.select(fieldNames.concat('__id'));
queryBuilder.offset(skip);
if (take !== -1) {
queryBuilder.limit(take);
}
const result = await this.prismaService
.txClient()
.$queryRawUnsafe<
(Pick<IRecord, 'fields'> & Pick<IVisualTableDefaultField, '__id'>)[]
>(queryBuilder.toQuery());
return result.map((record) => {
return {
id: record.__id,
fields: this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat),
};
});
}
async getRecordsHeadWithTitles(tableId: string, titles: string[]) {
const dbTableName = await this.getDbTableName(tableId);
const field = await this.prismaService.txClient().field.findFirst({
where: { tableId, isPrimary: true, deletedTime: null },
});
if (!field) {
throw new BadRequestException(`Could not find primary index ${tableId}`);
}
const queryBuilder = this.knex(dbTableName)
.select({ title: field.dbFieldName, id: '__id' })
.whereIn(field.dbFieldName, titles);
const querySql = queryBuilder.toQuery();
return this.prismaService.txClient().$queryRawUnsafe<{ id: string; title: string }[]>(querySql);
}
async getRecordsHeadWithIds(tableId: string, recordIds: string[]) {
const dbTableName = await this.getDbTableName(tableId);
const field = await this.prismaService.txClient().field.findFirst({
where: { tableId, isPrimary: true, deletedTime: null },
});
if (!field) {
throw new BadRequestException(`Could not find primary index ${tableId}`);
}
const queryBuilder = this.knex(dbTableName)
.select({ title: field.dbFieldName, id: '__id' })
.whereIn('__id', recordIds);
const querySql = queryBuilder.toQuery();
return this.prismaService.txClient().$queryRawUnsafe<{ id: string; title: string }[]>(querySql);
}
async filterRecordIdsByFilter(
tableId: string,
recordIds: string[],
filter?: IFilter | null
): Promise<string[]> {
const { queryBuilder, dbTableName } = await this.buildFilterSortQuery(tableId, {
filter,
});
queryBuilder.whereIn(`${dbTableName}.__id`, recordIds);
queryBuilder.select(this.knex.ref(`${dbTableName}.__id`));
const result = await this.prismaService
.txClient()
.$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
return result.map((r) => r.__id);
}
async getDiffIdsByIdAndFilter(tableId: string, recordIds: string[], filter?: IFilter | null) {
const ids = await this.filterRecordIdsByFilter(tableId, recordIds, filter);
return difference(recordIds, ids);
}
@Timing()
// eslint-disable-next-line sonarjs/cognitive-complexity
private async groupDbCollection2GroupPoints(
groupResult: { [key: string]: unknown; __c: number }[],
groupFields: IFieldInstance[],
collapsedGroupIds: string[] | undefined,
rowCount: number
) {
const groupPoints: IGroupPoint[] = [];
let fieldValues: unknown[] = [Symbol(), Symbol(), Symbol()];
let curRowCount = 0;
let collapsedDepth = Number.MAX_SAFE_INTEGER;
for (let i = 0; i < groupResult.length; i++) {
const item = groupResult[i];
const { __c: count } = item;
for (let index = 0; index < groupFields.length; index++) {
if (index > collapsedDepth) break;
const field = groupFields[index];
const { id, dbFieldName } = field;
const fieldValue = convertValueToStringify(item[dbFieldName]);
if (fieldValues[index] === fieldValue) continue;
// Reset the collapsedDepth when encountering the next peer grouping
collapsedDepth = Number.MAX_SAFE_INTEGER;
fieldValues[index] = fieldValue;
fieldValues = fieldValues.map((value, idx) => (idx > index ? Symbol() : value));
const flagString = `${id}_${fieldValues.slice(0, index + 1).join('_')}`;
const groupId = String(string2Hash(flagString));
const isCollapsedInner = collapsedGroupIds?.includes(groupId) ?? false;
let value = field.convertDBValue2CellValue(fieldValue);
if (field.type === FieldType.Attachment) {
value = await this.getAttachmentPresignedCellValue(value as IAttachmentCellValue);
}
groupPoints.push({
id: groupId,
type: GroupPointType.Header,
depth: index,
value,
isCollapsed: isCollapsedInner,
});
if (isCollapsedInner) {
collapsedDepth = index;
}
}
curRowCount += Number(count);
if (collapsedDepth !== Number.MAX_SAFE_INTEGER) continue;
groupPoints.push({ type: GroupPointType.Row, count: Number(count) });
}
if (curRowCount < rowCount) {
groupPoints.push(
{
id: 'unknown',
type: GroupPointType.Header,
depth: 0,
value: 'Unknown',
isCollapsed: false,
},
{ type: GroupPointType.Row, count: rowCount - curRowCount }
);
}
return groupPoints;
}
private getFilterByCollapsedGroup({
groupBy,
groupPoints,
fieldInstanceMap,
collapsedGroupIds,
}: {
groupBy: IGroup;
groupPoints: IGroupPointsVo;
fieldInstanceMap: Record<string, IFieldInstance>;
collapsedGroupIds?: string[];
}) {
if (!groupBy?.length || groupPoints == null || collapsedGroupIds == null) return null;
const groupIds: string[] = [];
const groupId2DataMap = groupPoints.reduce(
(prev, cur) => {
if (cur.type !== GroupPointType.Header) {
return prev;
}
const { id, depth } = cur;
groupIds[depth] = id;
prev[id] = { ...cur, path: groupIds.slice(0, depth + 1) };
return prev;
},
{} as Record<string, IGroupHeaderPoint & { path: string[] }>
);
const filterQuery: IFilter = {
conjunction: and.value,
filterSet: [],
};
for (const groupId of collapsedGroupIds) {
const groupData = groupId2DataMap[groupId];
if (groupData == null) continue;
const { path } = groupData;
const innerFilterSet: IFilterSet = {
conjunction: or.value,
filterSet: [],
};
path.forEach((pathGroupId) => {
const pathGroupData = groupId2DataMap[pathGroupId];
if (pathGroupData == null) return;
const { depth } = pathGroupData;
const curGroup = groupBy[depth];
if (curGroup == null) return;
const { fieldId } = curGroup;
const field = fieldInstanceMap[fieldId];
if (field == null) return;
const filterItem = generateFilterItem(field, pathGroupData.value);
innerFilterSet.filterSet.push(filterItem);
});
filterQuery.filterSet.push(innerFilterSet);
}
return filterQuery;
}
private async getRowCountByFilter(
dbTableName: string,
fieldInstanceMap: Record<string, IFieldInstance>,
filter?: IFilter,
search?: [string, string] | [string]
) {
const withUserId = this.cls.get('user.id');
const queryBuilder = this.knex(dbTableName);
if (filter) {
this.dbProvider
.filterQuery(queryBuilder, fieldInstanceMap, filter, { withUserId })
.appendQueryBuilder();
}
if (search) {
const handledSearch = search ? this.parseSearch(search, fieldInstanceMap) : undefined;
queryBuilder.where((builder) => {
this.dbProvider.searchQuery(builder, fieldInstanceMap, handledSearch);
});
}
const rowCountSql = queryBuilder.count({ count: '*' });
const result = await this.prismaService.$queryRawUnsafe<{ count?: number }[]>(
rowCountSql.toQuery()
);
return Number(result[0].count);
}
public async getGroupRelatedData(tableId: string, query?: IGetRecordsRo) {
const { viewId, groupBy: extraGroupBy, filter, search, collapsedGroupIds } = query || {};
let groupPoints: IGroupPoint[] = [];
const groupBy = parseGroup(extraGroupBy);
if (!groupBy?.length) {
return {
groupPoints,
filter,
};
}
const viewRaw = await this.getTinyView(tableId, viewId);
const fieldInstanceMap = (await this.getNecessaryFieldMap(
tableId,
filter,
undefined,
groupBy,
search
))!;
const fieldMapWithoutHiddenFields = this.getFieldMapWithoutHiddenFields(
fieldInstanceMap,
viewRaw?.columnMeta
);
const dbTableName = await this.getDbTableName(tableId);
const filterStr = viewRaw?.filter;
const mergedFilter = mergeWithDefaultFilter(filterStr, filter);
const groupFieldIds = groupBy.map((item) => item.fieldId);
const queryBuilder = this.knex(dbTableName);
if (mergedFilter) {
const withUserId = this.cls.get('user.id');
this.dbProvider
.filterQuery(queryBuilder, fieldInstanceMap, mergedFilter, { withUserId })
.appendQueryBuilder();
}
if (search) {
const handledSearch = search ? this.parseSearch(search, fieldInstanceMap) : undefined;
queryBuilder.where((builder) => {
this.dbProvider.searchQuery(builder, fieldMapWithoutHiddenFields, handledSearch);
});
}
this.dbProvider.sortQuery(queryBuilder, fieldInstanceMap, groupBy).appendSortBuilder();
this.dbProvider.groupQuery(queryBuilder, fieldInstanceMap, groupFieldIds).appendGroupBuilder();
queryBuilder.count({ __c: '*' }).limit(this.thresholdConfig.maxGroupPoints);
const groupSql = queryBuilder.toQuery();
const groupFields = groupFieldIds.map((fieldId) => fieldInstanceMap[fieldId]);
const rowCount = await this.getRowCountByFilter(
dbTableName,
fieldInstanceMap,
mergedFilter,
search
);
try {
const result =
await this.prismaService.$queryRawUnsafe<{ [key: string]: unknown; __c: number }[]>(
groupSql
);
groupPoints = await this.groupDbCollection2GroupPoints(
result,
groupFields,
collapsedGroupIds,
rowCount
);
} catch (error) {
console.log(`Get group points error in table ${tableId}: `, error);
}
const filterWithCollapsed = this.getFilterByCollapsedGroup({
groupBy,
groupPoints,
fieldInstanceMap,
collapsedGroupIds,
});
return { groupPoints, filter: mergeFilter(filter, filterWithCollapsed) };
}
async getRecordStatus(
tableId: string,
recordId: string,
query: IGetRecordsRo
): Promise<IRecordStatusVo> {
const dbTableName = await this.getDbTableName(tableId);
const queryBuilder = this.knex(dbTableName).select('__id').where('__id', recordId).limit(1);
const result = await this.prismaService
.txClient()
.$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
const isDeleted = result.length === 0;
if (isDeleted) {
return { isDeleted, isVisible: false };
}
const queryResult = await this.getDocIdsByQuery(tableId, {
viewId: query.viewId,
skip: query.skip,
take: query.take,
filter: query.filter,
orderBy: query.orderBy,
search: query.search,
groupBy: query.groupBy,
filterLinkCellCandidate: query.filterLinkCellCandidate,
filterLinkCellSelected: query.filterLinkCellSelected,
selectedRecordIds: query.selectedRecordIds,
});
const isVisible = queryResult.ids.includes(recordId);
return { isDeleted, isVisible };
}
}