feat(search): recherche intelligente — parsing backend + pg_trgm fuzzy
- parseSearchTokens() côté backend : extrait type (async/openapi), version (v1, 1.2.3) et latestOnly depuis la chaîne brute - Recherche fuzzy via word_similarity (pg_trgm, seuil 0.5) + ILIKE - latestOnly automatique quand champ non vide sans version explicite - Suppression de parseSearch() dans BrowseComponent et CatalogComponent - Suppression de version/latestOnly de ApiListQuery (géré par le backend) - Activation de pg_trgm via CREATE EXTENSION dans SeederService - Tests unitaires Vitest pour parseSearchTokens (24 cas) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -49,39 +49,107 @@ export class ApisService {
|
||||
}
|
||||
|
||||
async findAll(query: ApiListQuery): Promise<ApiListResponse> {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20)));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const qb = this.repo.createQueryBuilder('api');
|
||||
|
||||
if (query.type) {
|
||||
qb.andWhere('api.type = :type', { type: query.type as ApiType });
|
||||
let effectiveType = query.type as ApiType | undefined;
|
||||
let version: string | undefined;
|
||||
let latestOnly = false;
|
||||
|
||||
if (query.search?.trim()) {
|
||||
const parsed = this.parseSearchTokens(query.search);
|
||||
// Le type détecté dans la saisie override le select UI
|
||||
if (parsed.type) effectiveType = parsed.type;
|
||||
version = parsed.version;
|
||||
latestOnly = parsed.latestOnly;
|
||||
|
||||
if (parsed.text) {
|
||||
// Recherche fuzzy via pg_trgm + ILIKE pour les matches partiels courts
|
||||
qb.andWhere(
|
||||
`(
|
||||
api.name ILIKE :likeText
|
||||
OR api.provider ILIKE :likeText
|
||||
OR COALESCE(api.description, '') ILIKE :likeText
|
||||
OR api.contact_functional_name ILIKE :likeText
|
||||
OR api.contact_technical_name ILIKE :likeText
|
||||
OR word_similarity(:text, api.name) > 0.5
|
||||
OR word_similarity(:text, api.provider) > 0.5
|
||||
OR word_similarity(:text, COALESCE(api.description, '')) > 0.5
|
||||
)`,
|
||||
{ likeText: `%${parsed.text}%`, text: parsed.text },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
`(LOWER(api.name) LIKE :search
|
||||
OR LOWER(api.provider) LIKE :search
|
||||
OR LOWER(COALESCE(api.description, '')) LIKE :search
|
||||
OR LOWER(api.contact_functional_name) LIKE :search
|
||||
OR LOWER(api.contact_technical_name) LIKE :search)`,
|
||||
{ search: `%${query.search.toLowerCase()}%` },
|
||||
);
|
||||
|
||||
if (effectiveType) {
|
||||
qb.andWhere('api.type = :type', { type: effectiveType });
|
||||
}
|
||||
if (query.categoryId) {
|
||||
qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId });
|
||||
}
|
||||
|
||||
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
|
||||
// Filtre par version (partielle ou complète)
|
||||
if (version) {
|
||||
const parts = version.split('.').map(Number);
|
||||
if (parts.length >= 1 && !isNaN(parts[0]))
|
||||
qb.andWhere('api.version_major = :major', { major: parts[0] });
|
||||
if (parts.length >= 2 && !isNaN(parts[1]))
|
||||
qb.andWhere('api.version_minor = :minor', { minor: parts[1] });
|
||||
if (parts.length >= 3 && !isNaN(parts[2]))
|
||||
qb.andWhere('api.version_patch = :patch', { patch: parts[2] });
|
||||
}
|
||||
|
||||
// Latest-only : exclure les entrées dont il existe une version plus récente du même API
|
||||
if (latestOnly) {
|
||||
qb.andWhere(`NOT EXISTS (
|
||||
SELECT 1 FROM api_entries e2
|
||||
WHERE e2.name = api.name AND e2.convention = api.convention
|
||||
AND (
|
||||
e2.version_major > api.version_major
|
||||
OR (e2.version_major = api.version_major AND e2.version_minor > api.version_minor)
|
||||
OR (e2.version_major = api.version_major AND e2.version_minor = api.version_minor
|
||||
AND e2.version_patch > api.version_patch)
|
||||
)
|
||||
)`);
|
||||
}
|
||||
|
||||
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
|
||||
const [entities, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: entities.map(toApiEntryListItem),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
return { items: entities.map(toApiEntryListItem), total, page, limit };
|
||||
}
|
||||
|
||||
private parseSearchTokens(raw: string): {
|
||||
text: string;
|
||||
type?: ApiType;
|
||||
version?: string;
|
||||
latestOnly: boolean;
|
||||
} {
|
||||
const TYPE_MAP: Record<string, ApiType> = {
|
||||
async: 'ASYNCAPI', asyncapi: 'ASYNCAPI',
|
||||
openapi: 'OPENAPI', open: 'OPENAPI',
|
||||
};
|
||||
const VERSION_RE = /^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/i;
|
||||
let type: ApiType | undefined;
|
||||
let version: string | undefined;
|
||||
let latestOnly = false;
|
||||
const remaining: string[] = [];
|
||||
|
||||
for (const word of raw.trim().split(/\s+/).filter(Boolean)) {
|
||||
const lower = word.toLowerCase();
|
||||
if (lower === 'latest') { latestOnly = true; }
|
||||
else if (TYPE_MAP[lower]) { type = TYPE_MAP[lower]; }
|
||||
else if (VERSION_RE.test(word)) { version = word.replace(/^v/i, ''); }
|
||||
else { remaining.push(word); }
|
||||
}
|
||||
|
||||
const text = remaining.join(' ');
|
||||
// Pas de version explicite ET champ non vide → latest par défaut
|
||||
if (!latestOnly && raw.trim().length > 0 && !version) latestOnly = true;
|
||||
return { text, type, version, latestOnly };
|
||||
}
|
||||
|
||||
async findOneOrThrow(id: string): Promise<ApiEntry> {
|
||||
|
||||
Reference in New Issue
Block a user