strict search shapes

This commit is contained in:
timonrieger
2026-07-25 16:54:33 +02:00
parent 9b40ef069c
commit 8bcf23789d
6 changed files with 102 additions and 65 deletions
+2 -8
View File
@@ -42,7 +42,7 @@ class MetadataSearchDto {
this.personIds = const Optional.present(const []),
this.previewPath = const Optional.absent(),
this.rating = const Optional.absent(),
this.size = const Optional.absent(),
this.size = const Optional.present(250),
this.state = const Optional.absent(),
this.tagIds = const Optional.present(const []),
this.takenAfter = const Optional.absent(),
@@ -274,12 +274,6 @@ class MetadataSearchDto {
///
/// Minimum value: 1
/// Maximum value: 1000
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<int?> size;
/// Filter by state/province name
@@ -483,7 +477,7 @@ class MetadataSearchDto {
(personIds.hashCode) +
(previewPath == null ? 0 : previewPath!.hashCode) +
(rating == null ? 0 : rating!.hashCode) +
(size == null ? 0 : size!.hashCode) +
(size.hashCode) +
(state == null ? 0 : state!.hashCode) +
(tagIds == null ? 0 : tagIds!.hashCode) +
(takenAfter == null ? 0 : takenAfter!.hashCode) +
+2 -8
View File
@@ -31,7 +31,7 @@ class RandomSearchDto {
this.ocr = const Optional.absent(),
this.personIds = const Optional.present(const []),
this.rating = const Optional.absent(),
this.size = const Optional.absent(),
this.size = const Optional.present(250),
this.state = const Optional.absent(),
this.tagIds = const Optional.present(const []),
this.takenAfter = const Optional.absent(),
@@ -162,12 +162,6 @@ class RandomSearchDto {
///
/// Minimum value: 1
/// Maximum value: 1000
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<int?> size;
/// Filter by state/province name
@@ -339,7 +333,7 @@ class RandomSearchDto {
(ocr == null ? 0 : ocr!.hashCode) +
(personIds.hashCode) +
(rating == null ? 0 : rating!.hashCode) +
(size == null ? 0 : size!.hashCode) +
(size.hashCode) +
(state == null ? 0 : state!.hashCode) +
(tagIds == null ? 0 : tagIds!.hashCode) +
(takenAfter == null ? 0 : takenAfter!.hashCode) +
+2 -8
View File
@@ -36,7 +36,7 @@ class SmartSearchDto {
this.query = const Optional.absent(),
this.queryAssetId = const Optional.absent(),
this.rating = const Optional.absent(),
this.size = const Optional.absent(),
this.size = const Optional.present(100),
this.state = const Optional.absent(),
this.tagIds = const Optional.present(const []),
this.takenAfter = const Optional.absent(),
@@ -213,12 +213,6 @@ class SmartSearchDto {
///
/// Minimum value: 1
/// Maximum value: 1000
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<int?> size;
/// Filter by state/province name
@@ -380,7 +374,7 @@ class SmartSearchDto {
(query == null ? 0 : query!.hashCode) +
(queryAssetId == null ? 0 : queryAssetId!.hashCode) +
(rating == null ? 0 : rating!.hashCode) +
(size == null ? 0 : size!.hashCode) +
(size.hashCode) +
(state == null ? 0 : state!.hashCode) +
(tagIds == null ? 0 : tagIds!.hashCode) +
(takenAfter == null ? 0 : takenAfter!.hashCode) +
+10
View File
@@ -10645,6 +10645,7 @@
"schema": {
"minimum": 1,
"maximum": 1000,
"default": 250,
"type": "integer"
}
},
@@ -20716,6 +20717,7 @@
"type": "object"
},
"MetadataSearchDto": {
"additionalProperties": false,
"properties": {
"albumIds": {
"deprecated": true,
@@ -21308,6 +21310,7 @@
"x-immich-state": "Deprecated"
},
"size": {
"default": 250,
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
@@ -23149,6 +23152,7 @@
"type": "object"
},
"RandomSearchDto": {
"additionalProperties": false,
"properties": {
"albumIds": {
"deprecated": true,
@@ -23537,6 +23541,7 @@
"x-immich-state": "Deprecated"
},
"size": {
"default": 250,
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
@@ -24125,6 +24130,7 @@
"type": "object"
},
"SearchFilter": {
"additionalProperties": false,
"properties": {
"albumIds": {
"$ref": "#/components/schemas/IdsFilter"
@@ -24233,6 +24239,7 @@
"type": "object"
},
"SearchFilterBranch": {
"additionalProperties": false,
"properties": {
"albumIds": {
"$ref": "#/components/schemas/IdsFilter"
@@ -25334,6 +25341,7 @@
"type": "object"
},
"SmartSearchDto": {
"additionalProperties": false,
"properties": {
"albumIds": {
"deprecated": true,
@@ -25769,6 +25777,7 @@
"x-immich-state": "Deprecated"
},
"size": {
"default": 100,
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
@@ -26092,6 +26101,7 @@
"type": "object"
},
"StatisticsSearchDto": {
"additionalProperties": false,
"properties": {
"albumIds": {
"deprecated": true,
@@ -130,6 +130,42 @@ describe(SearchController.name, () => {
);
});
it('should reject an unknown field on the request body', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ filter: {}, oderBy: { field: 'rating' } });
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'Unrecognized key: "oderBy"' }]));
});
it('should reject an unknown key in the filter', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ filter: { previewPath: { eq: 'preview.webp' } } });
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['filter'], message: 'Unrecognized key: "previewPath"' }]));
});
it('should reject a nested or', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ filter: { or: [{ or: [{ city: { eq: 'Oslo' } }] }] } });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['filter', 'or', 0], message: 'Unrecognized key: "or"' }]),
);
});
it('should reject an empty or branch', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ filter: { or: [{}] } });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['filter', 'or', 0], message: 'At least one filter condition is required' }]),
);
});
describe('POST /search/random', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/search/random');
+50 -41
View File
@@ -244,47 +244,56 @@ export const SearchOrderSchema = z
})
.meta({ id: 'SearchOrder' });
const searchFilterBranchShape = {
id: IdFilterSchema,
libraryId: IdFilterNullableSchema,
type: EnumFilterAssetTypeSchema,
visibility: EnumFilterAssetVisibilitySchema,
isFavorite: BoolFilterSchema,
isMotion: BoolFilterSchema,
isOffline: BoolFilterSchema,
isEncoded: BoolFilterSchema,
hasAlbums: BoolFilterSchema,
hasPeople: BoolFilterSchema,
hasTags: BoolFilterSchema,
city: StringFilterNullableSchema,
state: StringFilterNullableSchema,
country: StringFilterNullableSchema,
make: StringFilterNullableSchema,
model: StringFilterNullableSchema,
lensModel: StringFilterNullableSchema,
description: StringPatternFilterSchema,
originalFileName: StringPatternFilterSchema,
originalPath: StringPatternFilterSchema,
ocr: StringSimilarityFilterSchema,
rating: NumberFilterNullableSchema,
fileSizeInBytes: NumberFilterSchema,
takenAt: DateFilterSchema,
createdAt: DateFilterSchema,
updatedAt: DateFilterSchema,
trashedAt: DateFilterNullableSchema,
personIds: IdsFilterSchema,
tagIds: IdsFilterSchema,
albumIds: IdsFilterSchema,
checksum: StringFilterSchema,
encodedVideoPath: StringFilterSchema,
};
const SearchFilterBranchSchema = z
.object({
id: IdFilterSchema,
libraryId: IdFilterNullableSchema,
type: EnumFilterAssetTypeSchema,
visibility: EnumFilterAssetVisibilitySchema,
isFavorite: BoolFilterSchema,
isMotion: BoolFilterSchema,
isOffline: BoolFilterSchema,
isEncoded: BoolFilterSchema,
hasAlbums: BoolFilterSchema,
hasPeople: BoolFilterSchema,
hasTags: BoolFilterSchema,
city: StringFilterNullableSchema,
state: StringFilterNullableSchema,
country: StringFilterNullableSchema,
make: StringFilterNullableSchema,
model: StringFilterNullableSchema,
lensModel: StringFilterNullableSchema,
description: StringPatternFilterSchema,
originalFileName: StringPatternFilterSchema,
originalPath: StringPatternFilterSchema,
ocr: StringSimilarityFilterSchema,
rating: NumberFilterNullableSchema,
fileSizeInBytes: NumberFilterSchema,
takenAt: DateFilterSchema,
createdAt: DateFilterSchema,
updatedAt: DateFilterSchema,
trashedAt: DateFilterNullableSchema,
personIds: IdsFilterSchema,
tagIds: IdsFilterSchema,
albumIds: IdsFilterSchema,
checksum: StringFilterSchema,
encodedVideoPath: StringFilterSchema,
})
.strictObject(searchFilterBranchShape)
.partial()
.refine((branch) => Object.values(branch).some((value) => value !== undefined), {
message: 'At least one filter condition is required',
})
.meta({ id: 'SearchFilterBranch' });
export const SearchFilterSchema = SearchFilterBranchSchema.extend({
or: z.array(SearchFilterBranchSchema).min(1).optional(),
}).meta({ id: 'SearchFilter' });
export const SearchFilterSchema = z
.strictObject(searchFilterBranchShape)
.partial()
.extend({
or: z.array(SearchFilterBranchSchema).min(1).optional(),
})
.meta({ id: 'SearchFilter' });
export type IdFilter = z.infer<typeof IdFilterSchema>;
export type IdFilterNullable = z.infer<typeof IdFilterNullableSchema>;
@@ -344,7 +353,7 @@ const RandomSearchBaseSchema = BaseSearchWithResultsSchema.extend({
filter: filterField,
});
const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema).meta({ id: 'RandomSearchDto' });
const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema.strict()).meta({ id: 'RandomSearchDto' });
const MetadataSearchSchema = withShapeExclusivity(
RandomSearchBaseSchema.extend({
@@ -360,14 +369,14 @@ const MetadataSearchSchema = withShapeExclusivity(
page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD),
orderBy: SearchOrderSchema.optional().meta(ADDED_V3_1),
cursor: cursorField,
}),
}).strict(),
).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = withShapeExclusivity(
BaseSearchSchema.extend({
description: z.string().trim().optional().describe('Filter by description text').meta(DEPRECATED_FLAT_FIELD),
filter: filterField,
}),
}).strict(),
).meta({ id: 'StatisticsSearchDto' });
const SmartSearchSchema = withShapeExclusivity(
@@ -379,7 +388,7 @@ const SmartSearchSchema = withShapeExclusivity(
page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD),
filter: filterField,
cursor: cursorField,
}),
}).strict(),
).meta({ id: 'SmartSearchDto' });
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}