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:
156
back/src/modules/apis/apis.service.spec.ts
Normal file
156
back/src/modules/apis/apis.service.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { ApisService } from './apis.service';
|
||||||
|
import { ApiType } from '@datacat/shared';
|
||||||
|
|
||||||
|
/** Accès à la méthode privée pour les tests unitaires */
|
||||||
|
function parse(raw: string) {
|
||||||
|
return (ApisService.prototype as any).parseSearchTokens.call({}, raw) as {
|
||||||
|
text: string;
|
||||||
|
type?: ApiType;
|
||||||
|
version?: string;
|
||||||
|
latestOnly: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ApisService.parseSearchTokens()', () => {
|
||||||
|
describe('chaîne vide', () => {
|
||||||
|
it('retourne text vide, pas de type, pas de version, latestOnly=false', () => {
|
||||||
|
expect(parse('')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gère les espaces seuls', () => {
|
||||||
|
expect(parse(' ')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('texte libre seul (sans version ni type)', () => {
|
||||||
|
it('"auth" → latestOnly=true automatique', () => {
|
||||||
|
expect(parse('auth')).toEqual({ text: 'auth', type: undefined, version: undefined, latestOnly: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"paiement commande" → texte libre multi-mots, latestOnly=true', () => {
|
||||||
|
expect(parse('paiement commande')).toEqual({
|
||||||
|
text: 'paiement commande',
|
||||||
|
type: undefined,
|
||||||
|
version: undefined,
|
||||||
|
latestOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('détection du type', () => {
|
||||||
|
it('"async" → ASYNCAPI', () => {
|
||||||
|
const r = parse('async');
|
||||||
|
expect(r.type).toBe('ASYNCAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"asyncapi" → ASYNCAPI', () => {
|
||||||
|
expect(parse('asyncapi').type).toBe('ASYNCAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"openapi" → OPENAPI', () => {
|
||||||
|
expect(parse('openapi').type).toBe('OPENAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"open" → OPENAPI', () => {
|
||||||
|
expect(parse('open').type).toBe('OPENAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('type seul → latestOnly=true (champ non vide, pas de version)', () => {
|
||||||
|
expect(parse('async').latestOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mots de type ne font pas partie du texte libre', () => {
|
||||||
|
expect(parse('auth async').text).toBe('auth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('détection de version', () => {
|
||||||
|
it('"v1" → version="1", latestOnly=false', () => {
|
||||||
|
expect(parse('v1')).toEqual({ text: '', type: undefined, version: '1', latestOnly: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"1.2" → version="1.2"', () => {
|
||||||
|
expect(parse('1.2').version).toBe('1.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"v1.2.3" → version="1.2.3" (préfixe v supprimé)', () => {
|
||||||
|
expect(parse('v1.2.3').version).toBe('1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"1.0.0" → version exacte', () => {
|
||||||
|
expect(parse('1.0.0').version).toBe('1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('version présente → latestOnly=false', () => {
|
||||||
|
expect(parse('auth v1').latestOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numéro de version ne fait pas partie du texte libre', () => {
|
||||||
|
expect(parse('auth v1').text).toBe('auth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mot-clé "latest"', () => {
|
||||||
|
it('"latest" seul → latestOnly=true, pas de texte', () => {
|
||||||
|
expect(parse('latest')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"latest" + type → latestOnly=true + type détecté', () => {
|
||||||
|
const r = parse('latest async');
|
||||||
|
expect(r.latestOnly).toBe(true);
|
||||||
|
expect(r.type).toBe('ASYNCAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"latest" + version → les deux flags actifs', () => {
|
||||||
|
const r = parse('latest v1');
|
||||||
|
expect(r.latestOnly).toBe(true);
|
||||||
|
expect(r.version).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combinaisons', () => {
|
||||||
|
it('"auth async" → texte=auth, type=ASYNCAPI, latestOnly=true', () => {
|
||||||
|
expect(parse('auth async')).toEqual({
|
||||||
|
text: 'auth',
|
||||||
|
type: 'ASYNCAPI',
|
||||||
|
version: undefined,
|
||||||
|
latestOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"auth async v1" → texte=auth, type=ASYNCAPI, version=1, latestOnly=false', () => {
|
||||||
|
expect(parse('auth async v1')).toEqual({
|
||||||
|
text: 'auth',
|
||||||
|
type: 'ASYNCAPI',
|
||||||
|
version: '1',
|
||||||
|
latestOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"auth 1.0.0" → texte=auth, version=1.0.0, latestOnly=false', () => {
|
||||||
|
expect(parse('auth 1.0.0')).toEqual({
|
||||||
|
text: 'auth',
|
||||||
|
type: undefined,
|
||||||
|
version: '1.0.0',
|
||||||
|
latestOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"openapi latest" → type=OPENAPI, latestOnly=true', () => {
|
||||||
|
expect(parse('openapi latest')).toEqual({
|
||||||
|
text: '',
|
||||||
|
type: 'OPENAPI',
|
||||||
|
version: undefined,
|
||||||
|
latestOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insensible à la casse pour les mots-clés', () => {
|
||||||
|
const r = parse('ASYNC Auth LATEST');
|
||||||
|
expect(r.type).toBe('ASYNCAPI');
|
||||||
|
expect(r.latestOnly).toBe(true);
|
||||||
|
expect(r.text).toBe('Auth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,33 +55,101 @@ export class ApisService {
|
|||||||
|
|
||||||
const qb = this.repo.createQueryBuilder('api');
|
const qb = this.repo.createQueryBuilder('api');
|
||||||
|
|
||||||
if (query.type) {
|
let effectiveType = query.type as ApiType | undefined;
|
||||||
qb.andWhere('api.type = :type', { type: query.type as ApiType });
|
let version: string | undefined;
|
||||||
}
|
let latestOnly = false;
|
||||||
if (query.search) {
|
|
||||||
|
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(
|
qb.andWhere(
|
||||||
`(LOWER(api.name) LIKE :search
|
`(
|
||||||
OR LOWER(api.provider) LIKE :search
|
api.name ILIKE :likeText
|
||||||
OR LOWER(COALESCE(api.description, '')) LIKE :search
|
OR api.provider ILIKE :likeText
|
||||||
OR LOWER(api.contact_functional_name) LIKE :search
|
OR COALESCE(api.description, '') ILIKE :likeText
|
||||||
OR LOWER(api.contact_technical_name) LIKE :search)`,
|
OR api.contact_functional_name ILIKE :likeText
|
||||||
{ search: `%${query.search.toLowerCase()}%` },
|
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 (effectiveType) {
|
||||||
|
qb.andWhere('api.type = :type', { type: effectiveType });
|
||||||
|
}
|
||||||
if (query.categoryId) {
|
if (query.categoryId) {
|
||||||
qb.andWhere('api.category_id = :categoryId', { categoryId: 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();
|
const [entities, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
return {
|
return { items: entities.map(toApiEntryListItem), total, page, limit };
|
||||||
items: entities.map(toApiEntryListItem),
|
}
|
||||||
total,
|
|
||||||
page,
|
private parseSearchTokens(raw: string): {
|
||||||
limit,
|
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> {
|
async findOneOrThrow(id: string): Promise<ApiEntry> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||||
import { CategoryEntity } from '../categories/category.entity';
|
import { CategoryEntity } from '../categories/category.entity';
|
||||||
@@ -593,9 +593,13 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
@InjectRepository(CategoryEntity)
|
@InjectRepository(CategoryEntity)
|
||||||
private readonly categoryRepo: Repository<CategoryEntity>,
|
private readonly categoryRepo: Repository<CategoryEntity>,
|
||||||
private readonly docsService: DocsGenerationService,
|
private readonly docsService: DocsGenerationService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap(): Promise<void> {
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
// Activer l'extension pg_trgm pour la recherche fuzzy
|
||||||
|
await this.dataSource.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
||||||
|
|
||||||
const forceReseed = process.env.FORCE_RESEED === 'true';
|
const forceReseed = process.env.FORCE_RESEED === 'true';
|
||||||
if (forceReseed) {
|
if (forceReseed) {
|
||||||
await this.reset();
|
await this.reset();
|
||||||
|
|||||||
9
back/vitest.config.ts
Normal file
9
back/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.spec.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -74,7 +74,7 @@ import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal
|
|||||||
<!-- Barre de recherche -->
|
<!-- Barre de recherche -->
|
||||||
<div class="fr-search-bar fr-mb-3w">
|
<div class="fr-search-bar fr-mb-3w">
|
||||||
<label class="fr-label" for="search-api">Rechercher</label>
|
<label class="fr-label" for="search-api">Rechercher</label>
|
||||||
<input id="search-api" class="fr-input" type="search" placeholder="Titre, description..."
|
<input id="search-api" class="fr-input" type="search" placeholder="Ex : auth async v1.0, openapi latest..."
|
||||||
[value]="searchQuery()"
|
[value]="searchQuery()"
|
||||||
(input)="onSearchChange($any($event.target).value)" />
|
(input)="onSearchChange($any($event.target).value)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -365,10 +365,11 @@ export class BrowseComponent implements OnInit {
|
|||||||
|
|
||||||
loadSearch() {
|
loadSearch() {
|
||||||
this.searchLoading.set(true);
|
this.searchLoading.set(true);
|
||||||
const q = this.searchQuery().trim().toLowerCase();
|
const raw = this.searchQuery();
|
||||||
|
const q = raw.trim().toLowerCase();
|
||||||
|
|
||||||
forkJoin({
|
forkJoin({
|
||||||
apis: this.apiService.list({ search: this.searchQuery(), page: this.searchPage(), limit: this.searchLimit }),
|
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
|
||||||
categories: this.categoryService.list(),
|
categories: this.categoryService.list(),
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: ({ apis, categories }) => {
|
next: ({ apis, categories }) => {
|
||||||
@@ -376,9 +377,7 @@ export class BrowseComponent implements OnInit {
|
|||||||
this.searchTotal.set(apis.total);
|
this.searchTotal.set(apis.total);
|
||||||
this.searchCategoryResults.set(
|
this.searchCategoryResults.set(
|
||||||
categories.items.filter(
|
categories.items.filter(
|
||||||
(c) =>
|
(c) => c.name.toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q),
|
||||||
c.name.toLowerCase().includes(q) ||
|
|
||||||
(c.description ?? '').toLowerCase().includes(q),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.searchLoading.set(false);
|
this.searchLoading.set(false);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared';
|
|||||||
id="search-input"
|
id="search-input"
|
||||||
class="fr-input"
|
class="fr-input"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Rechercher par titre ou description..."
|
placeholder="Ex : auth async v1.0, openapi latest..."
|
||||||
[ngModel]="search()"
|
[ngModel]="search()"
|
||||||
(ngModelChange)="onSearchChange($event)"
|
(ngModelChange)="onSearchChange($event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user