Skip to content

Commit a78a7ee

Browse files
committed
fix broken filters
1 parent 3c7eaae commit a78a7ee

2 files changed

Lines changed: 190 additions & 1 deletion

File tree

src/modules/games/games.controller.spec.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as nestjsPaginate from "nestjs-paginate";
2+
import * as nestjsPaginateFilter from "nestjs-paginate/lib/filter";
13
import { Repository } from "typeorm";
24
import { OtpService } from "../otp/otp.service";
35
import { Progress } from "../progresses/progress.entity";
@@ -9,6 +11,22 @@ import { GamesController } from "./games.controller";
911
import { GamesService } from "./games.service";
1012
import { GamevaultGame } from "./gamevault-game.entity";
1113

14+
jest.mock("nestjs-paginate", () => {
15+
const actual = jest.requireActual("nestjs-paginate");
16+
return {
17+
...actual,
18+
paginate: jest.fn(),
19+
};
20+
});
21+
22+
jest.mock("nestjs-paginate/lib/filter", () => {
23+
const actual = jest.requireActual("nestjs-paginate/lib/filter");
24+
return {
25+
...actual,
26+
addFilter: jest.fn(),
27+
};
28+
});
29+
1230
describe("GamesController", () => {
1331
let controller: GamesController;
1432
let gamesService: jest.Mocked<GamesService>;
@@ -57,6 +75,9 @@ describe("GamesController", () => {
5775
gamesRepository = {
5876
find: jest.fn(),
5977
createQueryBuilder: jest.fn(),
78+
manager: {
79+
getRepository: jest.fn(),
80+
},
6081
} as any;
6182

6283
progressRepository = {
@@ -79,6 +100,67 @@ describe("GamesController", () => {
79100
usersService,
80101
otpService,
81102
);
103+
104+
jest.clearAllMocks();
105+
});
106+
107+
describe("findGames", () => {
108+
it("should resolve metadata tag filters to game ids before paginate", async () => {
109+
const metadataQueryBuilder = {
110+
select: jest.fn().mockReturnThis(),
111+
distinct: jest.fn().mockReturnThis(),
112+
innerJoin: jest.fn().mockReturnThis(),
113+
getRawMany: jest.fn().mockResolvedValue([{ id: 7 }, { id: "9" }]),
114+
};
115+
116+
const metadataRepository = {
117+
createQueryBuilder: jest.fn().mockReturnValue(metadataQueryBuilder),
118+
};
119+
120+
(gamesRepository.manager.getRepository as jest.Mock).mockReturnValue(
121+
metadataRepository,
122+
);
123+
124+
(nestjsPaginateFilter.addFilter as jest.Mock).mockImplementation(() => {
125+
return {};
126+
});
127+
128+
(nestjsPaginate.paginate as jest.Mock).mockResolvedValue({
129+
data: [],
130+
meta: {} as any,
131+
links: {} as any,
132+
});
133+
134+
await controller.findGames({ user: createMockUser() }, {
135+
filter: {
136+
"metadata.tags.name": "$in:Action",
137+
},
138+
sortBy: [],
139+
path: "api/games",
140+
} as any);
141+
142+
expect(gamesRepository.manager.getRepository).toHaveBeenCalled();
143+
expect(nestjsPaginateFilter.addFilter).toHaveBeenCalledWith(
144+
metadataQueryBuilder,
145+
{
146+
filter: {
147+
"tags.name": "$in:Action",
148+
},
149+
},
150+
{
151+
"tags.name": true,
152+
},
153+
);
154+
expect(nestjsPaginate.paginate).toHaveBeenCalledWith(
155+
expect.objectContaining({
156+
filter: {
157+
id: "$in:7,9",
158+
},
159+
}),
160+
gamesRepository,
161+
expect.any(Object),
162+
);
163+
});
82164
});
83165

84166
describe("putFilesReindex", () => {

src/modules/games/games.controller.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ import { In, Not, Repository } from "typeorm";
4141
import { FileInterceptor } from "@nestjs/platform-express";
4242
import bytes from "bytes";
4343
import { isArray } from "lodash";
44-
import { FilterSuffix } from "nestjs-paginate/lib/filter";
44+
import { FilterSuffix, addFilter } from "nestjs-paginate/lib/filter";
4545
import configuration from "../../configuration";
4646
import { DisableApiIf } from "../../decorators/disable-api-if.decorator";
4747
import { MinimumRole } from "../../decorators/minimum-role.decorator";
4848
import { PaginateQueryOptions } from "../../decorators/pagination.decorator";
4949
import { ApiOkResponsePaginated, toFindOptionsRelations } from "../../globals";
50+
import { GameMetadata } from "../metadata/games/game.metadata.entity";
5051
import { OtpService } from "../otp/otp.service";
5152
import { State } from "../progresses/models/state.enum";
5253
import { Progress } from "../progresses/progress.entity";
@@ -59,6 +60,15 @@ import { GamevaultGame } from "./gamevault-game.entity";
5960
import { GameIdDto } from "./models/game-id.dto";
6061
import { UpdateGameDto } from "./models/update-game.dto";
6162

63+
const metadataRelationNameFilters = {
64+
"metadata.genres.name": "genres.name",
65+
"metadata.tags.name": "tags.name",
66+
"metadata.developers.name": "developers.name",
67+
"metadata.publishers.name": "publishers.name",
68+
} as const;
69+
70+
type MetadataRelationNameFilterKey = keyof typeof metadataRelationNameFilters;
71+
6272
@ApiBearerAuth()
6373
@ApiTags("game")
6474
@Controller("games")
@@ -224,6 +234,14 @@ export class GamesController {
224234
}
225235
}
226236

237+
const relationFilteredGameIds =
238+
await this.resolveMetadataRelationNameFilterGameIds(query);
239+
240+
if (relationFilteredGameIds) {
241+
this.removeMetadataRelationNameFilters(query);
242+
this.applyResolvedGameIdFilter(query, relationFilteredGameIds);
243+
}
244+
227245
if (
228246
configuration.PARENTAL.AGE_RESTRICTION_ENABLED &&
229247
request.user.role !== Role.ADMIN
@@ -483,4 +501,93 @@ export class GamesController {
483501

484502
return query;
485503
}
504+
505+
private async resolveMetadataRelationNameFilterGameIds(
506+
query: PaginateQuery,
507+
): Promise<number[] | undefined> {
508+
const activeFilters = Object.entries(metadataRelationNameFilters).filter(
509+
([filterKey]) => query.filter?.[filterKey] != null,
510+
) as Array<
511+
[
512+
MetadataRelationNameFilterKey,
513+
(typeof metadataRelationNameFilters)[MetadataRelationNameFilterKey],
514+
]
515+
>;
516+
517+
if (activeFilters.length === 0) {
518+
return undefined;
519+
}
520+
521+
let matchingIds: number[] | undefined;
522+
523+
for (const [queryFilterKey, metadataFilterKey] of activeFilters) {
524+
const filterValue = query.filter?.[queryFilterKey];
525+
526+
if (filterValue == null) {
527+
continue;
528+
}
529+
530+
const metadataQuery = {
531+
filter: {
532+
[metadataFilterKey]: filterValue,
533+
},
534+
} as PaginateQuery;
535+
536+
const queryBuilder = this.gamesRepository.manager
537+
.getRepository(GameMetadata)
538+
.createQueryBuilder("metadata")
539+
.select("game.id", "id")
540+
.distinct(true)
541+
.innerJoin(GamevaultGame, "game", "game.metadata_id = metadata.id");
542+
543+
addFilter(queryBuilder, metadataQuery, {
544+
[metadataFilterKey]: true,
545+
});
546+
547+
const resolvedIds = (
548+
await queryBuilder.getRawMany<{ id: number | string }>()
549+
)
550+
.map(({ id }) => Number(id))
551+
.filter((id) => Number.isInteger(id));
552+
553+
if (matchingIds == null) {
554+
matchingIds = resolvedIds;
555+
} else {
556+
const resolvedIdSet = new Set(resolvedIds);
557+
matchingIds = matchingIds.filter((id) => resolvedIdSet.has(id));
558+
}
559+
560+
if (matchingIds.length === 0) {
561+
break;
562+
}
563+
}
564+
565+
return matchingIds;
566+
}
567+
568+
private removeMetadataRelationNameFilters(query: PaginateQuery) {
569+
for (const filterKey of Object.keys(metadataRelationNameFilters)) {
570+
delete query.filter?.[filterKey];
571+
}
572+
}
573+
574+
private applyResolvedGameIdFilter(query: PaginateQuery, gameIds: number[]) {
575+
query.filter ??= {};
576+
577+
const resolvedIdFilter =
578+
gameIds.length > 0 ? `$in:${gameIds.join(",")}` : "$eq:-1";
579+
const existingIdFilter = query.filter.id;
580+
581+
if (existingIdFilter == null) {
582+
query.filter.id = resolvedIdFilter;
583+
return;
584+
}
585+
586+
if (isArray(existingIdFilter)) {
587+
query.filter.id = [...existingIdFilter, resolvedIdFilter];
588+
return;
589+
}
590+
591+
query.filter.id = [existingIdFilter, resolvedIdFilter];
592+
}
486593
}

0 commit comments

Comments
 (0)