Files
twenty/packages/twenty-server/src/engine/metadata-modules/object-metadata/mostly-empty-fields.service.ts
T
Félix Malfait 5e27e04c0a Add mostly-empty field hints to data model settings (#22962)
## What

Fields that are empty in almost all records now show a subtle `Mostly
empty` hint next to their name in the object's Fields settings table
(same visual treatment as `Deactivated`), with a tooltip explaining the
signal and a matching **Mostly empty** toggle in the search filter
dropdown. The goal is to nudge admins to clean up and deactivate fields
nobody uses, while keeping the page untouched when the data model is
healthy.

## How

**No table scans.** Emptiness is read from Postgres planner statistics,
so the cost is a catalog lookup regardless of table size:

- `pg_class.reltuples` gates the feature on an approximate row count (≥
100 records; never-analyzed tables mean no hints). Reuses the shared
helper extracted from `ObjectRecordCountService`.
- `pg_stats.null_frac` plus the sampled frequency of the column type's
empty sentinel (`''` for text columns, `'{}'` for arrays, `'{}'`/`'[]'`
for json — matched per physical column type) gives a per-column empty
fraction. A value dominating ≥ 95% of a column is guaranteed to appear
in the most-common-values list, so the approximation is reliable exactly
at the threshold we care about.

**Decision rules** (pure util, unit-tested):

- Flag when every relevant column is ≥ 95% empty and the object has ≥
100 records.
- Skip system fields, the label identifier, relations, booleans, and
actor fields (exhaustive switch — a new `FieldMetadataType` fails to
compile until classified).
- Composite fields must have all their columns empty, with column sets
derived from `compositeTypeDefinitions`; only default-bearing code
columns (`currencyCode`, phone country/calling codes) are excluded so
stamped defaults don't mask emptiness.
- Anything unknown (missing stats, new column since last ANALYZE)
degrades to silence — no hint is ever shown on missing data.

**API:** one `mostlyEmptyFieldMetadataIds(objectMetadataId)` query on
the metadata schema, guarded by the `DATA_MODEL` settings permission,
fetched lazily when the fields page opens.

**UI:** exception-based — no new columns, no persistent controls. The
badge and the filter toggle only materialize when at least one field
qualifies, and disappear once things are cleaned up.

## Test

- Unit tests for the decision util (threshold,
system/label-identifier/inactive exclusion, missing statistics,
composite all-columns rule, links label/secondary data, currency
narrowing, excluded types).
- Catalog SQL validated against Postgres 16 with a table mimicking
Twenty's column shapes (text `''` defaults, enums, arrays, jsonb,
currency pairs), including the type-aware sentinel matching (a text
column full of literal `"{}"` strings does not count as empty).
- End-to-end on a seeded dev instance: 899 companies with a mix of
filled/empty fields — the API returned exactly the five fields predicted
by the raw statistics (`annualRevenue`, `employees`, `introVideo`,
`tagline`, `workPolicy`) and correctly excluded `address` (city 33%
filled), actor/system fields, and the label identifier.
- UI driven with Playwright: badge, tooltip copy, filter toggle, and
filtered table all verified visually.
- `lint:diff-with-main`, `typecheck` (server + front), and all three
`graphql:generate` configurations + SDK metadata client regenerated and
committed.
2026-07-17 12:03:11 +00:00

119 lines
5.2 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { MOSTLY_EMPTY_MINIMUM_ROW_COUNT } from 'src/engine/metadata-modules/object-metadata/constants/mostly-empty-minimum-row-count.constant';
import { ObjectRecordCountService } from 'src/engine/metadata-modules/object-metadata/object-record-count.service';
import { computeMostlyEmptyFieldMetadataIds } from 'src/engine/metadata-modules/object-metadata/utils/compute-mostly-empty-field-metadata-ids.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
// Detects fields that are empty in almost all records of an object, reading
// Postgres planner statistics (pg_class / pg_stats) instead of scanning the
// table: cost is a catalog lookup regardless of table size, at the price of
// approximate results — acceptable for a settings-page hint
@Injectable()
export class MostlyEmptyFieldsService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly objectRecordCountService: ObjectRecordCountService,
) {}
async getMostlyEmptyFieldMetadataIds({
workspaceId,
objectMetadataId,
}: {
workspaceId: string;
objectMetadataId: string;
}): Promise<string[]> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const schemaName = getWorkspaceSchemaName(workspaceId);
const tableName = computeObjectTargetTable(flatObjectMetadata);
const approximateRecordCountByTableName =
await this.objectRecordCountService.getApproximateRecordCountByTableName(
workspaceId,
);
const approximateRowCount =
approximateRecordCountByTableName.get(tableName) ?? 0;
if (approximateRowCount < MOSTLY_EMPTY_MINIMUM_ROW_COUNT) {
return [];
}
const dataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
// Per-column emptiness: null fraction plus the sampled frequency of the
// column type's empty sentinel — '' for text columns (NOT NULL DEFAULT ''),
// '{}' for arrays, '{}'/'[]' for json. Sentinels are matched per physical
// column type so a text value that happens to be '{}' does not count
const columnStatisticsRows: {
column_name: string;
empty_fraction: number;
}[] = await dataSource.query(
`SELECT s.attname AS column_name,
(s.null_frac + COALESCE(empty_sentinel.frequency, 0))::float AS empty_fraction
FROM pg_stats s
JOIN pg_namespace n ON n.nspname = s.schemaname
JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = s.tablename
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = s.attname
JOIN pg_type t ON t.oid = a.atttypid
LEFT JOIN LATERAL (
SELECT SUM(most_common_value_frequency) AS frequency
FROM unnest(s.most_common_vals::text::text[], s.most_common_freqs)
AS most_common_value_entry(most_common_value, most_common_value_frequency)
WHERE most_common_value_entry.most_common_value = ANY (
CASE
WHEN t.typcategory = 'S' THEN ARRAY['']
WHEN t.typcategory = 'A' THEN ARRAY['{}']
WHEN t.typname IN ('json', 'jsonb') THEN ARRAY['{}', '[]']
ELSE ARRAY[]::text[]
END
)
) empty_sentinel ON TRUE
WHERE s.schemaname = $1
AND s.tablename = $2
AND NOT s.inherited`,
[schemaName, tableName],
undefined,
{ shouldBypassPermissionChecks: true },
);
const emptyFractionByColumnName = new Map(
columnStatisticsRows.map((row) => [
row.column_name,
Number(row.empty_fraction),
]),
);
const flatFieldMetadatas = findManyFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityIds: flatObjectMetadata.fieldIds,
flatEntityMaps: flatFieldMetadataMaps,
});
return computeMostlyEmptyFieldMetadataIds({
fieldMetadatas: flatFieldMetadatas,
labelIdentifierFieldMetadataId:
flatObjectMetadata.labelIdentifierFieldMetadataId,
emptyFractionByColumnName,
});
}
}