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](https://github.com/twentyhq/core-team-issues/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.
This commit is contained in:
@@ -1062,6 +1062,7 @@ export enum FeatureFlagKey {
|
||||
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
|
||||
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
|
||||
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
|
||||
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
|
||||
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
|
||||
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
|
||||
@@ -1026,6 +1026,7 @@ export enum FeatureFlagKey {
|
||||
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
|
||||
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
|
||||
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
|
||||
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
|
||||
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
|
||||
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSearchFieldMetadataEntity1757806282417
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddSearchFieldMetadataEntity1757806282417';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`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"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID" ON "core"."searchFieldMetadata" ("objectMetadataId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID" ON "core"."searchFieldMetadata" ("workspaceId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_1b78544eb06f82059a2a01013a3" FOREIGN KEY ("objectMetadataId") REFERENCES "core"."objectMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_6d5c6922bfd1578b1eff2abb9d6" FOREIGN KEY ("fieldMetadataId") REFERENCES "core"."fieldMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_6d5c6922bfd1578b1eff2abb9d6"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_1b78544eb06f82059a2a01013a3"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "core"."IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "core"."IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "core"."searchFieldMetadata"`);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWorkspaceForeignKeyToSearchFieldMetadata1757809958470
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddWorkspaceForeignKeyToSearchFieldMetadata1757809958470';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_5f10e00da471e19f52513f47d8b" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_5f10e00da471e19f52513f47d8b"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
@@ -18,4 +18,5 @@ export enum FeatureFlagKey {
|
||||
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
|
||||
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RemoteServerModule } from 'src/engine/metadata-modules/remote-server/remote-server.module';
|
||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module';
|
||||
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
|
||||
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
|
||||
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';
|
||||
DataSourceModule,
|
||||
FieldMetadataModule,
|
||||
ObjectMetadataModule,
|
||||
SearchFieldMetadataModule,
|
||||
ServerlessFunctionModule,
|
||||
AgentModule,
|
||||
WorkspaceMetadataVersionModule,
|
||||
@@ -31,6 +33,7 @@ import { RouteModule } from 'src/engine/metadata-modules/route/route.module';
|
||||
DataSourceModule,
|
||||
FieldMetadataModule,
|
||||
ObjectMetadataModule,
|
||||
SearchFieldMetadataModule,
|
||||
ServerlessFunctionModule,
|
||||
AgentModule,
|
||||
RemoteServerModule,
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Entity('searchFieldMetadata')
|
||||
@Unique('IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE', [
|
||||
'objectMetadataId',
|
||||
'fieldMetadataId',
|
||||
])
|
||||
@Index('IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID', ['workspaceId'])
|
||||
@Index('IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID', ['objectMetadataId'])
|
||||
export class SearchFieldMetadataEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
objectMetadataId: string;
|
||||
|
||||
@ManyToOne(() => ObjectMetadataEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'objectMetadataId' })
|
||||
objectMetadata: Relation<ObjectMetadataEntity>;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
fieldMetadataId: string;
|
||||
|
||||
@ManyToOne(() => FieldMetadataEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'fieldMetadataId' })
|
||||
fieldMetadata: Relation<FieldMetadataEntity>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => Workspace, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SearchFieldMetadataEntity])],
|
||||
providers: [],
|
||||
exports: [],
|
||||
})
|
||||
export class SearchFieldMetadataModule {}
|
||||
+1
@@ -141,6 +141,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
IS_CALENDAR_VIEW_ENABLED: false,
|
||||
IS_GROUP_BY_ENABLED: false,
|
||||
IS_PUBLIC_DOMAIN_ENABLED: false,
|
||||
IS_DYNAMIC_SEARCH_FIELDS_ENABLED: false,
|
||||
},
|
||||
eventEmitterService: {
|
||||
emitMutationEvent: jest.fn(),
|
||||
|
||||
+5
@@ -85,6 +85,11 @@ export const seedFeatureFlags = async (
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_DYNAMIC_SEARCH_FIELDS_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user