Skip to content

Commit ff3467c

Browse files
feat (search): add dynamic search field metadata foundation for database-driven search configuration (#14469)
## Summary Implements the foundation for dynamic search field configuration from [issue #1428](twentyhq/core-team-issues#1428). ## Changes - Add `SearchFieldMetadataEntity` junction table for storing searchable field configurations - Add `SearchFieldMetadataService` with core CRUD operations - Add `SearchFieldMetadataModule` following existing patterns - Add `IS_DYNAMIC_SEARCH_FIELDS_ENABLED` feature flag (defaults to `false`) - Database migration with proper indexes and foreign keys ## Architecture Uses a junction table where **record existence = field is searchable**: **Table: `searchFieldMetadata(objectMetadataId, fieldMetadataId, workspaceId)`** - Unique constraint on `(objectMetadataId, fieldMetadataId)` - `ON DELETE CASCADE` on foreign keys ## Testing - [x] Migration runs successfully - [x] Table created with correct schema - [x] Feature flag seeded properly - [x] Database reset works correctly ## Next Steps Future PRs will handle: - Data migration from existing hardcoded search field configs - Integration with search services ✅ No breaking changes — fully backward compatible.
1 parent a89cc3b commit ff3467c

File tree

10 files changed

+138
-0
lines changed

10 files changed

+138
-0
lines changed

packages/twenty-front/src/generated-metadata/graphql.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,7 @@ export enum FeatureFlagKey {
10621062
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
10631063
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
10641064
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
1065+
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
10651066
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
10661067
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
10671068
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',

packages/twenty-front/src/generated/graphql.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,7 @@ export enum FeatureFlagKey {
10261026
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
10271027
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
10281028
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
1029+
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
10291030
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
10301031
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
10311032
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { type MigrationInterface, type QueryRunner } from 'typeorm';
2+
3+
export class AddSearchFieldMetadataEntity1757806282417
4+
implements MigrationInterface
5+
{
6+
name = 'AddSearchFieldMetadataEntity1757806282417';
7+
8+
public async up(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query(
10+
`CREATE TABLE "core"."searchFieldMetadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "objectMetadataId" uuid NOT NULL, "fieldMetadataId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "workspaceId" uuid NOT NULL, CONSTRAINT "IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE" UNIQUE ("objectMetadataId", "fieldMetadataId"), CONSTRAINT "PK_085190eb7531f4aeb8ccab3f42c" PRIMARY KEY ("id"))`,
11+
);
12+
await queryRunner.query(
13+
`CREATE INDEX "IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID" ON "core"."searchFieldMetadata" ("objectMetadataId") `,
14+
);
15+
await queryRunner.query(
16+
`CREATE INDEX "IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID" ON "core"."searchFieldMetadata" ("workspaceId") `,
17+
);
18+
await queryRunner.query(
19+
`ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_1b78544eb06f82059a2a01013a3" FOREIGN KEY ("objectMetadataId") REFERENCES "core"."objectMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
20+
);
21+
await queryRunner.query(
22+
`ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_6d5c6922bfd1578b1eff2abb9d6" FOREIGN KEY ("fieldMetadataId") REFERENCES "core"."fieldMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
23+
);
24+
}
25+
26+
public async down(queryRunner: QueryRunner): Promise<void> {
27+
await queryRunner.query(
28+
`ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_6d5c6922bfd1578b1eff2abb9d6"`,
29+
);
30+
await queryRunner.query(
31+
`ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_1b78544eb06f82059a2a01013a3"`,
32+
);
33+
await queryRunner.query(
34+
`DROP INDEX "core"."IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID"`,
35+
);
36+
await queryRunner.query(
37+
`DROP INDEX "core"."IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID"`,
38+
);
39+
await queryRunner.query(`DROP TABLE "core"."searchFieldMetadata"`);
40+
}
41+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { type MigrationInterface, type QueryRunner } from 'typeorm';
2+
3+
export class AddWorkspaceForeignKeyToSearchFieldMetadata1757809958470
4+
implements MigrationInterface
5+
{
6+
name = 'AddWorkspaceForeignKeyToSearchFieldMetadata1757809958470';
7+
8+
public async up(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query(
10+
`ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_5f10e00da471e19f52513f47d8b" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
11+
);
12+
}
13+
14+
public async down(queryRunner: QueryRunner): Promise<void> {
15+
await queryRunner.query(
16+
`ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_5f10e00da471e19f52513f47d8b"`,
17+
);
18+
}
19+
}

packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ export enum FeatureFlagKey {
1818
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
1919
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
2020
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
21+
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
2122
}

packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
77
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
88
import { RemoteServerModule } from 'src/engine/metadata-modules/remote-server/remote-server.module';
99
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
10+
import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module';
1011
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
1112
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
1213
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@@ -17,6 +18,7 @@ import { RouteModule } from 'src/engine/metadata-modules/route/route.module';
1718
DataSourceModule,
1819
FieldMetadataModule,
1920
ObjectMetadataModule,
21+
SearchFieldMetadataModule,
2022
ServerlessFunctionModule,
2123
AgentModule,
2224
WorkspaceMetadataVersionModule,
@@ -31,6 +33,7 @@ import { RouteModule } from 'src/engine/metadata-modules/route/route.module';
3133
DataSourceModule,
3234
FieldMetadataModule,
3335
ObjectMetadataModule,
36+
SearchFieldMetadataModule,
3437
ServerlessFunctionModule,
3538
AgentModule,
3639
RemoteServerModule,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
Column,
3+
CreateDateColumn,
4+
Entity,
5+
Index,
6+
JoinColumn,
7+
ManyToOne,
8+
PrimaryGeneratedColumn,
9+
Relation,
10+
Unique,
11+
UpdateDateColumn,
12+
} from 'typeorm';
13+
14+
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
15+
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
16+
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
17+
18+
@Entity('searchFieldMetadata')
19+
@Unique('IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE', [
20+
'objectMetadataId',
21+
'fieldMetadataId',
22+
])
23+
@Index('IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID', ['workspaceId'])
24+
@Index('IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID', ['objectMetadataId'])
25+
export class SearchFieldMetadataEntity {
26+
@PrimaryGeneratedColumn('uuid')
27+
id: string;
28+
29+
@Column({ nullable: false, type: 'uuid' })
30+
objectMetadataId: string;
31+
32+
@ManyToOne(() => ObjectMetadataEntity, { onDelete: 'CASCADE' })
33+
@JoinColumn({ name: 'objectMetadataId' })
34+
objectMetadata: Relation<ObjectMetadataEntity>;
35+
36+
@Column({ nullable: false, type: 'uuid' })
37+
fieldMetadataId: string;
38+
39+
@ManyToOne(() => FieldMetadataEntity, { onDelete: 'CASCADE' })
40+
@JoinColumn({ name: 'fieldMetadataId' })
41+
fieldMetadata: Relation<FieldMetadataEntity>;
42+
43+
@CreateDateColumn({ type: 'timestamptz' })
44+
createdAt: Date;
45+
46+
@UpdateDateColumn({ type: 'timestamptz' })
47+
updatedAt: Date;
48+
49+
@Column({ nullable: false, type: 'uuid' })
50+
workspaceId: string;
51+
52+
@ManyToOne(() => Workspace, { onDelete: 'CASCADE' })
53+
@JoinColumn({ name: 'workspaceId' })
54+
workspace: Relation<Workspace>;
55+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
4+
import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity';
5+
6+
@Module({
7+
imports: [TypeOrmModule.forFeature([SearchFieldMetadataEntity])],
8+
providers: [],
9+
exports: [],
10+
})
11+
export class SearchFieldMetadataModule {}

packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ describe('WorkspaceEntityManager', () => {
141141
IS_CALENDAR_VIEW_ENABLED: false,
142142
IS_GROUP_BY_ENABLED: false,
143143
IS_PUBLIC_DOMAIN_ENABLED: false,
144+
IS_DYNAMIC_SEARCH_FIELDS_ENABLED: false,
144145
},
145146
eventEmitterService: {
146147
emitMutationEvent: jest.fn(),

packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export const seedFeatureFlags = async (
8585
workspaceId: workspaceId,
8686
value: false,
8787
},
88+
{
89+
key: FeatureFlagKey.IS_DYNAMIC_SEARCH_FIELDS_ENABLED,
90+
workspaceId: workspaceId,
91+
value: false,
92+
},
8893
])
8994
.execute();
9095
};

0 commit comments

Comments
 (0)