mirror of
https://github.com/docmost/docmost.git
synced 2026-07-14 16:36:43 +10:00
feat(base): add /bases/rows/count with estimate and capped-exact modes
Row-count display on a filtered view shouldn't force a full COUNT(*) on
every list fetch. New endpoint returns either an EXPLAIN-plan estimate
(default, ~1ms, no execution) or a LIMIT-capped exact count that short-
circuits to `{ capped: true }` once the match set passes EXACT_COUNT_CAP.
Clients call it in parallel with the rows query so the grid still paints
at its own pace.
- DTO + repo.countEstimate/countExact reusing the list predicate shape
- service picks the mode; controller mirrors the list Read ability check
- client hook keyed by filter/search/exact so a "show exact" toggle
doesn't clobber the estimate cache
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
RowIdDto,
|
||||
ListRowsDto,
|
||||
ReorderRowDto,
|
||||
CountRowsDto,
|
||||
} from '../dto/update-row.dto';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
@@ -160,6 +161,26 @@ export class BaseRowController {
|
||||
return this.baseRowService.list(dto, pagination, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('count')
|
||||
async count(
|
||||
@Body() dto: CountRowsDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.count(dto, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('reorder')
|
||||
async reorder(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
@@ -83,6 +84,25 @@ export class ListRowsDto {
|
||||
search?: unknown;
|
||||
}
|
||||
|
||||
export class CountRowsDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filter?: unknown;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
search?: unknown;
|
||||
|
||||
// Opt-in for an exact (but capped) count. Default is an EXPLAIN-plan
|
||||
// estimate — cheap and good enough for "≈ N rows" headers.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
export class ReorderRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DeleteRowsDto,
|
||||
ListRowsDto,
|
||||
ReorderRowDto,
|
||||
CountRowsDto,
|
||||
} from '../dto/update-row.dto';
|
||||
import {
|
||||
BasePropertyTypeValue,
|
||||
@@ -46,6 +47,12 @@ import {
|
||||
} from '../events/base-events';
|
||||
import { FormulaService } from '../formula/formula.service';
|
||||
|
||||
// Cap for `count({ exact: true })`. Beyond this we return `capped: true` and
|
||||
// the UI shows "N+". Chosen so a cell-wise scan stays well under 100ms on
|
||||
// the existing `idx_base_rows_cells_gin_path_ops` index; callers that need
|
||||
// true totals should fall back to `exact: false` (planner estimate).
|
||||
const EXACT_COUNT_CAP = 10_000;
|
||||
|
||||
@Injectable()
|
||||
export class BaseRowService {
|
||||
constructor(
|
||||
@@ -244,6 +251,39 @@ export class BaseRowService {
|
||||
});
|
||||
}
|
||||
|
||||
async count(dto: CountRowsDto, workspaceId: string) {
|
||||
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
|
||||
const schema: PropertySchema = new Map(properties.map((p) => [p.id, p]));
|
||||
|
||||
const filter = this.normaliseFilter({ filter: dto.filter });
|
||||
const search = this.normaliseSearch(dto.search);
|
||||
|
||||
if (dto.exact) {
|
||||
const { value, capped } = await this.baseRowRepo.countExact({
|
||||
baseId: dto.baseId,
|
||||
workspaceId,
|
||||
filter,
|
||||
search,
|
||||
schema,
|
||||
cap: EXACT_COUNT_CAP,
|
||||
});
|
||||
return { value, exact: true as const, capped };
|
||||
}
|
||||
|
||||
const estimate = await this.baseRowRepo.countEstimate({
|
||||
baseId: dto.baseId,
|
||||
workspaceId,
|
||||
filter,
|
||||
search,
|
||||
schema,
|
||||
});
|
||||
return {
|
||||
value: estimate ?? 0,
|
||||
exact: false as const,
|
||||
capped: false,
|
||||
};
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderRowDto, workspaceId: string, userId?: string) {
|
||||
const row = await this.baseRowRepo.findById(dto.rowId, { workspaceId });
|
||||
if (!row || row.baseId !== dto.baseId) {
|
||||
@@ -274,7 +314,7 @@ export class BaseRowService {
|
||||
|
||||
// --- private helpers ------------------------------------------------
|
||||
|
||||
private normaliseFilter(dto: ListRowsDto): FilterNode | undefined {
|
||||
private normaliseFilter(dto: { filter?: unknown }): FilterNode | undefined {
|
||||
if (!dto.filter) return undefined;
|
||||
|
||||
const parsed = filterGroupSchema.safeParse(dto.filter);
|
||||
|
||||
Reference in New Issue
Block a user