Skip to content

Commit 2e4705f

Browse files
committed
implementation des filtres OR et negation
1 parent 73116d9 commit 2e4705f

22 files changed

Lines changed: 659 additions & 294 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common';
2+
import { DEFAULT_SCHEMA_OPTIONS, FilterSchemaOptions } from '@tacxou/nestjs_module_restools/search-filter-schema';
3+
import { filterSchemaWithGroups } from '~/_common/functions/filter-schema-groups.function';
4+
5+
/**
6+
* Remplace `SearchFilterSchema` de restools en ajoutant le support des groupes OU (`filters[i][...]`)
7+
*
8+
* @see filterSchemaWithGroups
9+
*/
10+
export const SearchFilterSchema = createParamDecorator(
11+
(options: FilterSchemaOptions | undefined, ctx: ExecutionContext) => {
12+
const resolved = { ...DEFAULT_SCHEMA_OPTIONS, ...options };
13+
const req = ctx.switchToHttp().getRequest();
14+
try {
15+
return filterSchemaWithGroups(req.query[resolved.queryKey], resolved);
16+
} catch (error) {
17+
throw new BadRequestException(error instanceof Error ? error.message : String(error));
18+
}
19+
},
20+
);
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {
2+
filterSchema,
3+
FilterSchema,
4+
FilterSchemaOptions,
5+
SearchFilterInput,
6+
} from '@tacxou/nestjs_module_restools/search-filter-schema';
7+
8+
export type FilterGroup = Record<string, unknown>;
9+
10+
/**
11+
* Filtre au format des clés signées : un groupe de conditions combinées par ET,
12+
* ou une liste de groupes combinés par OU
13+
*/
14+
export type FilterGroups = FilterGroup | FilterGroup[];
15+
16+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
17+
return value !== null && typeof value === 'object' && !Array.isArray(value);
18+
}
19+
20+
/**
21+
* Retourne la liste des groupes OU du filtre, ou null s'il s'agit d'un simple groupe ET.
22+
* qs transforme les tableaux de plus de 20 éléments en objets indexés (`{ "0": ..., "21": ... }`) : ils sont aussi acceptés.
23+
*/
24+
export function extractFilterGroups(filters: unknown): FilterGroup[] | null {
25+
if (Array.isArray(filters)) {
26+
if (filters.some((group) => !isPlainRecord(group))) {
27+
throw new Error('Invalid filters: expected an array of filter groups');
28+
}
29+
return filters;
30+
}
31+
32+
if (isPlainRecord(filters)) {
33+
const keys = Object.keys(filters);
34+
if (keys.length > 0 && keys.every((key) => /^\d+$/.test(key) && isPlainRecord(filters[key]))) {
35+
return keys.sort((a, b) => Number(a) - Number(b)).map((key) => filters[key] as FilterGroup);
36+
}
37+
}
38+
39+
return null;
40+
}
41+
42+
/**
43+
* Préfixe de négation d'une condition : `!<signe><champ>` (ex: `!^mail` = ne contient pas)
44+
*/
45+
export const FILTER_NEGATION_PREFIX = '!';
46+
47+
/**
48+
* Inverse une condition Mongo d'un champ : `$not` pour une expression d'opérateurs, `$ne` pour une égalité
49+
*/
50+
function negateCondition(condition: unknown): unknown {
51+
const isOperatorExpression = isPlainRecord(condition) && Object.keys(condition).some((key) => key.startsWith('$'));
52+
return isOperatorExpression ? { $not: condition } : { $ne: condition };
53+
}
54+
55+
/**
56+
* Convertit un groupe de conditions combinées par ET, en gérant la négation `!` de chaque condition.
57+
* restools ne sait nier que `:`, `#` et `@` : la négation est donc appliquée ici, sur le résultat de la condition positive.
58+
*/
59+
function filterGroupSchema(group: FilterGroup, options?: FilterSchemaOptions): FilterSchema {
60+
const positive: FilterGroup = {};
61+
const negated: Record<string, unknown>[] = [];
62+
63+
for (const [key, value] of Object.entries(group)) {
64+
if (!key.startsWith(FILTER_NEGATION_PREFIX)) {
65+
positive[key] = value;
66+
continue;
67+
}
68+
69+
const condition = filterSchema({ [key.slice(1)]: value } as SearchFilterInput, options);
70+
for (const [field, fieldCondition] of Object.entries(condition)) {
71+
negated.push({ [field]: negateCondition(fieldCondition) });
72+
}
73+
}
74+
75+
const result: Record<string, unknown> = { ...filterSchema(positive as SearchFilterInput, options) };
76+
const conflicts: Record<string, unknown>[] = [];
77+
for (const condition of negated) {
78+
const [field, fieldCondition] = Object.entries(condition)[0];
79+
if (field in result) conflicts.push(condition);
80+
else result[field] = fieldCondition;
81+
}
82+
if (conflicts.length) result.$and = conflicts;
83+
84+
return result as FilterSchema;
85+
}
86+
87+
/**
88+
* Extension de `filterSchema` (restools) supportant des groupes de conditions reliés par un OU
89+
* et la négation de chaque condition.
90+
*
91+
* Format accepté :
92+
* - `{ "<signe><champ>": valeur, ... }` : conditions combinées par ET (format historique)
93+
* - `[ { ...conditions ET... }, { ... } ]` : (groupe 1) OU (groupe 2) ...
94+
* (en query : `filters[0][:sn]=x&filters[1][^mail]=/foo/i`)
95+
* - `{ "!<signe><champ>": valeur }` : négation de la condition (ex: `!^mail` = ne contient pas)
96+
*
97+
* Le OU est encapsulé dans un `$and` afin de ne pas entrer en conflit avec le `$or`
98+
* de la recherche textuelle que les controllers fusionnent au même niveau.
99+
*/
100+
export function filterSchemaWithGroups(
101+
filters: SearchFilterInput | FilterGroups,
102+
options?: FilterSchemaOptions,
103+
): FilterSchema {
104+
const groups = extractFilterGroups(filters);
105+
if (!groups) {
106+
return isPlainRecord(filters)
107+
? filterGroupSchema(filters, options)
108+
: filterSchema(filters as SearchFilterInput, options);
109+
}
110+
111+
const conditions = groups
112+
.map((group) => filterGroupSchema(group, options))
113+
.filter((condition) => Object.keys(condition).length > 0);
114+
115+
if (conditions.length === 0) return {};
116+
if (conditions.length === 1) return conditions[0];
117+
118+
return { $and: [{ $or: conditions }] } as unknown as FilterSchema;
119+
}
120+
121+
/**
122+
* Indique si un filtre Mongo issu de `filterSchemaWithGroups` porte une condition sur la clé donnée,
123+
* à la racine ou dans un groupe `$and` / `$or`
124+
*/
125+
export function filterSchemaHasKey(schema: unknown, key: string): boolean {
126+
if (!isPlainRecord(schema)) return false;
127+
if (Object.prototype.hasOwnProperty.call(schema, key)) return true;
128+
129+
return ['$and', '$or'].some(
130+
(operator) =>
131+
Array.isArray(schema[operator]) && schema[operator].some((sub: unknown) => filterSchemaHasKey(sub, key)),
132+
);
133+
}

‎apps/api/src/core/agents/agents.controller.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,8 @@ import {
1313
Res,
1414
} from '@nestjs/common';
1515
import { ApiParam, ApiTags } from '@nestjs/swagger';
16-
import {
17-
FilterOptions,
18-
FilterSchema,
19-
SearchFilterOptions,
20-
SearchFilterSchema,
21-
} from '@tacxou/nestjs_module_restools/search-filter-schema';
16+
import { FilterOptions, FilterSchema, SearchFilterOptions } from '@tacxou/nestjs_module_restools/search-filter-schema';
17+
import { SearchFilterSchema } from '~/_common/decorators/search-filter-schema.decorator';
2218
import { Request, Response } from 'express';
2319
import { Types } from 'mongoose';
2420
import { AbstractController } from '~/_common/abstracts/abstract.controller';

‎apps/api/src/core/audits/audits.controller.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,8 @@ import { PartialProjectionType } from '~/_common/types/partial-projection.type';
55
import { AuditsService } from '~/core/audits/audits.service';
66
import { UseRoles } from '~/_common/decorators/use-roles.decorator';
77
import { AC_ACTIONS, AC_DEFAULT_POSSESSION } from '~/_common/types/ac-types';
8-
import {
9-
FilterOptions,
10-
FilterSchema,
11-
SearchFilterOptions,
12-
SearchFilterSchema,
13-
} from '@tacxou/nestjs_module_restools/search-filter-schema';
8+
import { FilterOptions, FilterSchema, SearchFilterOptions } from '@tacxou/nestjs_module_restools/search-filter-schema';
9+
import { SearchFilterSchema } from '~/_common/decorators/search-filter-schema.decorator';
1410
import { Response } from 'express';
1511
import { ObjectIdValidationPipe } from '~/_common/pipes/object-id-validation.pipe';
1612
import { Types } from 'mongoose';

‎apps/api/src/core/filestorage/filestorage.controller.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,8 @@ import {
1515
} from '@nestjs/common';
1616
import { FileInterceptor } from '@nestjs/platform-express';
1717
import { ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger';
18-
import {
19-
FilterOptions,
20-
FilterSchema,
21-
SearchFilterOptions,
22-
SearchFilterSchema,
23-
} from '@tacxou/nestjs_module_restools/search-filter-schema';
18+
import { FilterOptions, FilterSchema, SearchFilterOptions } from '@tacxou/nestjs_module_restools/search-filter-schema';
19+
import { SearchFilterSchema } from '~/_common/decorators/search-filter-schema.decorator';
2420
import { ObjectIdValidationPipe } from '~/_common/pipes/object-id-validation.pipe';
2521
import { Response } from 'express';
2622
import { Types } from 'mongoose';

‎apps/api/src/core/form/form.controller.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,8 @@ import { FormDto, FormUpdateDto } from './_dto/form.dto';
33
import { FormService } from './form.service';
44
import { AbstractController } from '~/_common/abstracts/abstract.controller';
55
import { ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
6-
import {
7-
SearchFilterSchema,
8-
FilterSchema,
9-
SearchFilterOptions,
10-
FilterOptions,
11-
} from '@tacxou/nestjs_module_restools/search-filter-schema';
6+
import { FilterSchema, SearchFilterOptions, FilterOptions } from '@tacxou/nestjs_module_restools/search-filter-schema';
7+
import { SearchFilterSchema } from '~/_common/decorators/search-filter-schema.decorator';
128
import { ObjectIdValidationPipe } from '~/_common/pipes/object-id-validation.pipe';
139
import { Types } from 'mongoose';
1410
import { ApiCreateDecorator } from '~/_common/decorators/api-create.decorator';

‎apps/api/src/core/keyrings/keyrings.controller.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,8 @@ import { ApiDeletedResponseDecorator } from '~/_common/decorators/api-deleted-re
66
import { ObjectIdValidationPipe } from '~/_common/pipes/object-id-validation.pipe';
77
import { Response } from 'express';
88
import { PickProjectionHelper } from '~/_common/helpers/pick-projection.helper';
9-
import {
10-
FilterOptions,
11-
FilterSchema,
12-
SearchFilterOptions,
13-
SearchFilterSchema,
14-
} from '@tacxou/nestjs_module_restools/search-filter-schema';
9+
import { FilterOptions, FilterSchema, SearchFilterOptions } from '@tacxou/nestjs_module_restools/search-filter-schema';
10+
import { SearchFilterSchema } from '~/_common/decorators/search-filter-schema.decorator';
1511
import { ApiCreateDecorator } from '~/_common/decorators/api-create.decorator';
1612
import { ApiPaginatedDecorator } from '~/_common/decorators/api-paginated.decorator';
1713
import { PartialProjectionType } from '~/_common/types/partial-projection.type';

‎apps/api/src/core/roles/roles.controller.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,8 @@ import { Response } from 'express';
88
import { ApiPaginatedDecorator } from '~/_common/decorators/api-paginated.decorator';
99
import { PickProjectionHelper } from '~/_common/helpers/pick-projection.helper';
1010
import { PartialProjectionType } from '~/_common/types/partial-projection.type';
11-
import {
12-
FilterOptions,
13-
FilterSchema,
14-
SearchFilterOptions,
15-
SearchFilterSchema,
16-
} from '@tacxou/nestjs_module_restools/search-filter-schema';
11+
import { FilterOptions, FilterSchema, SearchFilterOptions } from '@tacxou/nestjs_module_restools/search-filter-schema';
12+
import { SearchFilterSchema } from '~/_common/decorators/search-filter-schema.decorator';
1713
import { ObjectIdValidationPipe } from '~/_common/pipes/object-id-validation.pipe';
1814
import { ApiReadResponseDecorator } from '~/_common/decorators/api-read-response.decorator';
1915
import { Types } from 'mongoose';

‎apps/api/src/management/groups/_dto/groups.dto.ts‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,34 @@ import {
77
IsEnum,
88
IsMongoId,
99
IsNotEmpty,
10-
IsObject,
1110
IsOptional,
1211
IsString,
12+
ValidateBy,
13+
ValidationOptions,
1314
} from 'class-validator';
1415
import { CustomFieldsDto } from '~/_common/abstracts/dto/custom-fields.dto';
1516
import { IdentityState } from '~/management/identities/_enums/states.enum';
17+
import { FilterGroups } from '~/_common/functions/filter-schema-groups.function';
1618
import { GroupType } from '../_schemas/groups.schema';
1719

20+
const isFilterGroup = (value: unknown): boolean => value !== null && typeof value === 'object' && !Array.isArray(value);
21+
22+
/**
23+
* Filtre de groupe dynamique : un groupe de conditions (ET) ou une liste de groupes (OU)
24+
*/
25+
function IsFilterGroups(validationOptions?: ValidationOptions): PropertyDecorator {
26+
return ValidateBy(
27+
{
28+
name: 'isFilterGroups',
29+
validator: {
30+
validate: (value: unknown) => isFilterGroup(value) || (Array.isArray(value) && value.every(isFilterGroup)),
31+
defaultMessage: () => '$property doit être un objet de filtres ou une liste d’objets de filtres',
32+
},
33+
},
34+
validationOptions,
35+
);
36+
}
37+
1838
export class GroupsCreateDto extends CustomFieldsDto {
1939
@IsString()
2040
@IsNotEmpty()
@@ -46,15 +66,17 @@ export class GroupsCreateDto extends CustomFieldsDto {
4666
})
4767
public type?: GroupType;
4868

49-
@IsObject()
69+
@IsFilterGroups()
5070
@IsOptional()
5171
@ApiProperty({
5272
type: Object,
5373
required: false,
5474
nullable: true,
55-
description: 'Filtre de sélection des membres d’un groupe dynamique (clés signées, ex: { "@state": ["1"] })',
75+
description:
76+
'Filtre de sélection des membres d’un groupe dynamique (clés signées, ex: { "@state": ["1"] }), ' +
77+
'ou liste de filtres combinés par OU (ex: [{ "@state": ["1"] }, { ":sn": "x" }])',
5678
})
57-
public filters?: Record<string, unknown> | null;
79+
public filters?: FilterGroups | null;
5880

5981
@IsArray()
6082
@IsMongoId({ each: true })
@@ -99,9 +121,9 @@ export class GroupsMembersDto {
99121
}
100122

101123
export class GroupsFiltersPreviewDto {
102-
@IsObject()
103-
@ApiProperty({ type: Object, description: 'Filtre de groupe dynamique à évaluer' })
104-
public filters: Record<string, unknown>;
124+
@IsFilterGroups()
125+
@ApiProperty({ type: Object, description: 'Filtre de groupe dynamique à évaluer (objet ou liste de groupes OU)' })
126+
public filters: FilterGroups;
105127
}
106128

107129
export class GroupsMemberOfDto {

‎apps/api/src/management/groups/_schemas/groups.schema.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
22
import { Document, Types } from 'mongoose';
33
import { AbstractSchema } from '~/_common/abstracts/schemas/abstract.schema';
4+
import { FilterGroups } from '~/_common/functions/filter-schema-groups.function';
45
import { MixedValue } from '~/_common/types/mixed-value.type';
56
import { historyPlugin } from '~/_common/plugins/mongoose/history.plugin';
67
import { IdentityState } from '~/management/identities/_enums/states.enum';
@@ -36,11 +37,12 @@ export class Groups extends AbstractSchema {
3637

3738
/**
3839
* Filtre de sélection des membres d'un groupe dynamique, au format des clés signées
39-
* de `filters[...]` (ex: `{ "@state": ["1"], "^inetOrgPerson.employeeType": "/^etd/i" }`).
40+
* de `filters[...]` (ex: `{ "@state": ["1"], "^inetOrgPerson.employeeType": "/^etd/i" }`),
41+
* ou liste de tels filtres combinés par OU.
4042
* Le résultat de son évaluation est stocké dans `member`.
4143
*/
4244
@Prop({ type: Object, default: null })
43-
public filters?: Record<string, unknown> | null;
45+
public filters?: FilterGroups | null;
4446

4547
@Prop({ type: [Types.ObjectId], ref: 'Identities', default: [] })
4648
public member: Types.ObjectId[];

0 commit comments

Comments
 (0)