Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/interfaces/SearchTermInterface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface SearchTermInterface {
field: string;
term: string;
options: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,18 @@ export default class GlobalCharacterAggregateEntity {
default: Ps2AlertsEventType.LIVE_METAGAME,
})
ps2AlertsEventType: Ps2AlertsEventType;

@Exclude()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prevents this being persisted to the entity but allows us to add it to objects in results

@ApiProperty({
example: 100,
description: 'Search score weighting',
})
searchScore?: number;

@Exclude()
@ApiProperty({
example: 'character',
description: 'Search result type',
})
searchResultType?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,18 @@ export default class GlobalOutfitAggregateEntity {
default: Ps2AlertsEventType.LIVE_METAGAME,
})
ps2AlertsEventType: Ps2AlertsEventType;

@Exclude()
@ApiProperty({
example: 100,
description: 'Search score weighting',
})
searchScore?: number;

@Exclude()
@ApiProperty({
example: 'character',
description: 'Search result type',
})
searchResultType?: string;
}
151 changes: 151 additions & 0 deletions src/modules/rest/controllers/rest.search.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import {Controller, Get, Inject, Optional, Query} from '@nestjs/common';
import MongoOperationsService from '../../../services/mongo/mongo.operations.service';
import {ApiOperation, ApiResponse, ApiTags} from '@nestjs/swagger';
import {ApiImplicitQueries} from 'nestjs-swagger-api-implicit-queries-decorator';
import {PAGINATION_IMPLICIT_QUERIES} from './common/rest.pagination.queries';
import GlobalCharacterAggregateEntity from '../../data/entities/aggregate/global/global.character.aggregate.entity';
import GlobalOutfitAggregateEntity from '../../data/entities/aggregate/global/global.outfit.aggregate.entity';
import Pagination from '../../../services/mongo/pagination';
import {SearchTermInterface} from '../../../interfaces/SearchTermInterface';

@ApiTags('Search')
@Controller('search')
export default class RestSearchController {
constructor(
@Inject(MongoOperationsService) private readonly mongoOperationsService: MongoOperationsService,
) {}

@Get()
@ApiOperation({summary: 'Searches GlobalCharacterAggregateEntity and GlobalOutfitAggregateEntity for a term'})
@ApiImplicitQueries([...PAGINATION_IMPLICIT_QUERIES, {
name: 'type',
required: false,
description: 'The type of the data to be searched, either "characters" or "outfits". If not specified, both types will be searched.',
type: String,
enum: ['characters', 'outfits'],
}])
@ApiResponse({
status: 200,
description: 'The list of GlobalCharacterAggregateEntity and GlobalOutfitAggregateEntity for a search term',
type: Object,
isArray: false,
})
async search(
@Query('searchTerm') searchTerm: string,
@Query('type') @Optional() type?: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably split this endpoint into two, one for characters and one for outfits. I also would use an actual enum as the type.

Also you might want to look at the NestJS Swagger CLI plugin.

@Query('sortBy') sortBy?: string,
@Query('order') order?: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems odd parameter to have for a search. You want the best result at the top?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot, oversight

): Promise<Array<GlobalCharacterAggregateEntity | GlobalOutfitAggregateEntity>> {
let characterResults: GlobalCharacterAggregateEntity[] = [];
let outfitResults: GlobalOutfitAggregateEntity[] = [];

const pagination = new Pagination({sortBy, order, page: 0, pageSize: 10}, false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this conflicts with your search algorithm, as the subset might ignore better results.


if (type === 'characters' || type === undefined) {
const characterSearchTerm: SearchTermInterface = {
field: 'character.name',
term: searchTerm,
options: 'i',
};
characterResults = await this.mongoOperationsService.searchText(
GlobalCharacterAggregateEntity,
characterSearchTerm,
{$and: [{bracket: 0}]},
pagination,
);
}

if (type === 'outfits' || type === undefined) {
const outfitNameSearchTerm: SearchTermInterface = {
field: 'outfit.name',
term: searchTerm,
options: 'i',
};
const outfitTagSearchTerm: SearchTermInterface = {
field: 'outfit.tag',
term: searchTerm,
options: 'i',
};

// First, search for outfits by tag
const outfitTagResults = await this.mongoOperationsService.searchText(
GlobalOutfitAggregateEntity,
outfitTagSearchTerm,
{$and: [{bracket: 0}]},
pagination,
);

const outfitNameResults = await this.mongoOperationsService.searchText(
GlobalOutfitAggregateEntity,
outfitNameSearchTerm,
{$and: [{bracket: 0}]},
pagination,
);

// Combine both arrays, ensuring that the tag results appear first
outfitResults = [...outfitTagResults, ...outfitNameResults];

// Deduplicate outfits based on name
const outfitsMap = new Map(outfitResults.map((outfit) => [outfit.outfit.name, outfit]));
outfitResults = Array.from(outfitsMap.values());
}

const searchTermLower = searchTerm.toLowerCase();

// For outfits
outfitResults.forEach((outfit) => {
let score = 0;

// Higher weight for exact matches on tag and name
if (outfit.outfit.tag?.toLowerCase() === searchTermLower || outfit.outfit.name.toLowerCase() === searchTermLower) {
score += 100;
} else {
// Lower weight for partial matches
const searchTermRegex = new RegExp(searchTerm, 'i');

if (outfit.outfit.tag?.match(searchTermRegex)) {
score += 20;
} else if (outfit.outfit.name.match(searchTermRegex)) {
score += 10;
}
}

outfit.searchScore = score;
outfit.searchResultType = 'outfit'; // added type field
});

// For characters
characterResults.forEach((character) => {
let score = 0;

// Higher weight for exact matches
if (character.character.name.toLowerCase() === searchTermLower) {
score += 100;
} else {
// Lower weight for partial matches
const searchTermRegex = new RegExp(searchTerm, 'i');

if (character.character.name.match(searchTermRegex)) {
score += 5;
}
}

character.searchScore = score;
character.searchResultType = 'character'; // added type field
});

// Sort by score
characterResults.sort((a, b) => this.searchScores(a.searchScore, b.searchScore));

// Combine both arrays and return combined array sorted by score
return [...characterResults, ...outfitResults].sort((a, b) => this.searchScores(a.searchScore, b.searchScore));
}

private searchScores(a: number | undefined, b: number | undefined): number {
if (a && b) {
return b - a;
}

return 0;
}
}
2 changes: 2 additions & 0 deletions src/modules/rest/rest.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {ConfigService} from '@nestjs/config';
import * as redisStore from 'cache-manager-ioredis';
import {RedisCacheService} from '../../services/cache/redis.cache.service';
import {AuthModule} from '../../auth/auth.module';
import RestSearchController from './controllers/rest.search.controller';

/**
* Handles incoming requests to the API via HTTP, CRUD environment.
Expand Down Expand Up @@ -136,6 +137,7 @@ import {AuthModule} from '../../auth/auth.module';
RestInstanceFacilityControlController,
RestInstanceMetagameController,
RestOutfitwarsController,
RestSearchController,
],
providers: [
{provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor},
Expand Down
18 changes: 18 additions & 0 deletions src/services/mongo/mongo.operations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,24 @@ export default class MongoOperationsService {
}
}

public async searchText<T>(entity: new () => T, searchTerm?: {field: string, term: string, options: string}, filter?: object, pagination?: Pagination): Promise<T[]> {
// Create a base filter with bracket = 0
const baseFilter: { [key: string]: any } = {bracket: 0};

// If a search term is provided, add a regex query for the specified field
if (searchTerm) {
baseFilter[searchTerm.field] = {$regex: `^${searchTerm.term}.*`, $options: searchTerm.options};
}

// If an additional filter is provided, add it to the base filter
if (filter) {
Object.assign(baseFilter, filter);
}

// Use the find() method with the filter and pagination options
return await this.em.find(entity, MongoOperationsService.createFindOptions(baseFilter, pagination));
}

// eslint-disable-next-line @typescript-eslint/ban-types
private static createFindOptions(filter?: {[k: string]: any}, pagination?: Pagination): object {
let findOptions: {[k: string]: any} = {};
Expand Down