Elasticsearch — CRUD et Specifications¶
Copy of
sumaris-pod/.agents/rules/elasticsearch.md(rule written for AI coding agents, when creating or modifying Elasticsearch repositories, specifications, VO indexing, or CRUD operations with Elasticsearch). Do not edit here: runnpm run site:sync-rulesto update it.
Le projet utilise Spring Data Elasticsearch avec une API de Specification « maison » inspirée de JPA Specifications, permettant de composer des requêtes ES de manière fluide et null-safe.
Architecture générale¶
Le pattern CRUD Elasticsearch suit cette structure (exemple de référence : VesselSnapshot) :
VO annoté ES ← VesselSnapshotVO (@Document, @Field, @Setting)
│
├── ElasticsearchSpecifications ← VesselSnapshotElasticsearchSpecifications (interface, méthodes default)
│
├── ElasticsearchRepository ← VesselSnapshotElasticsearchRepository (interface Spring Data ES)
│
└── ElasticsearchRepositoryImpl ← VesselSnapshotElasticsearchRepositoryImpl (implémentation custom)
1. Value Object (VO) annoté pour Elasticsearch¶
Le VO sert à la fois pour le transport GraphQL/REST et comme document ES. Il est annoté avec les annotations Spring Data Elasticsearch :
@Getter
@Setter
@FieldNameConstants
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@Document(indexName = MyEntityVO.INDEX_PREFIX_EL + MyEntityVO.INDEX, createIndex = false)
@Setting(settingPath = "settings/whitespace-analyzer.json")
public class MyEntityVO implements IDataVO<Integer> {
@GraphQLIgnore
public static final String INDEX_PREFIX_EL = "#{@environment.getProperty('spring.elasticsearch.index.prefix', '')}";
@GraphQLIgnore
public static final String INDEX = "my_entity";
@Id
private Integer id; // Clé unique pour l'indexation ES
@Field(type = FieldType.Text, fielddata = true, searchAnalyzer = "whitespace_analyzer", analyzer = "code_analyzer")
private String name;
@Field(type = FieldType.Text, fielddata = true, searchAnalyzer = "whitespace_analyzer", analyzer = "code_analyzer")
private String code;
@Field(type = FieldType.Long)
private Date startDate;
@Field(type = FieldType.Long)
private Date endDate;
@Field(type = FieldType.Long)
private Date updateDate;
@Field(type = FieldType.Nested)
private LocationVO location;
@Field(type = FieldType.Nested)
private ReferentialVO type;
@Field(type = FieldType.Text, index = false)
private String comments;
}
Règles pour les annotations @Field :
FieldType.Longpour les dates (stockées en millisecondes epoch).FieldType.Textavecfielddata = truepour les champs texte triables/agrégables.FieldType.Nestedpour les objets imbriqués (LocationVO, ReferentialVO, ProgramVO, etc.).index = falsepour les champs non recherchables (comments, etc.).searchAnalyzer = "whitespace_analyzer", analyzer = "code_analyzer"pour tout champ texte recherché viasearchText()(noms, codes/identifiants) : voir "Choix de l'analyzer" ci-dessous.
Choix de l'analyzer (important) :
searchText()(section 3) construit deswildcardQuery/prefixQuery, qui ne sont jamais analysées : le texte tapé est comparé littéralement aux termes déjà stockés dans l'index.- N'utilisez jamais
analyzer = "standard"sur un champ recherché ainsi : le tokenizer standard découpe les termes sur toute ponctuation (-,_,/,., etc. — ex:"AB-1234"indexé comme 2 termes"ab"/"1234"), ce qui rend introuvable tout texte de recherche contenant ces caractères. - Utilisez
analyzer = "code_analyzer"(tokenizercode_tokenizer, ne découpe que sur les espaces) : un code comme"AB-1234"est indexé comme un seul terme"ab-1234", cohérent avec une recherche littérale (wildcard/prefix) sur le même texte. code_analyzeretwhitespace_analyzersont fonctionnellement identiques (les deux ne découpent que sur les espaces) ;code_analyzerest le nom conventionnel pour l'analyzerd'indexation,whitespace_analyzerpour lesearchAnalyzer(utilisé seulement par les requêtes analysées typematch, pas parwildcardQuery/prefixQuery).
Règles pour l'index :
createIndex = false: l'index est créé programmatiquement dans leRepositoryImpl.- Le nom d'index est préfixé via SpEL :
#{@environment.getProperty('spring.elasticsearch.index.prefix', '')}. - Le fichier de settings (
whitespace-analyzer.json) est danssrc/main/resources/settings/. - ⚠️ Changer l'
analyzerd'un champ existant ne s'applique jamais rétroactivement : Elasticsearch n'autorise pas la modification du mapping d'un champ déjà indexé. Il faut supprimer puis recréer l'index (recreate()=delete()+createWithMapping(), section 6) et relancer un réindexage complet pour que les documents déjà indexés basculent sur le nouvel analyzer.
2. API ElasticsearchSpecification (Specification « maison »)¶
L'interface ElasticsearchSpecification<T extends QueryBuilder> est un @FunctionalInterface qui retourne un QueryBuilder nullable. Elle offre une API fluide similaire aux JPA Specifications :
// Création d'une spécification bool
ElasticsearchSpecification.bool()
.filter(spec1) // Ajoute en clause filter (pas de scoring)
.filter(spec2)
.must(spec3) // Ajoute en clause must (avec scoring)
.should(spec4) // Ajoute en clause should
Comportement null-safe : si une spec passée à .filter(), .must() ou .should() est null, elle est ignorée. Cela permet de chaîner conditionnellement sans if.
Méthode statique utilitaire :
ElasticsearchSpecification.bool(): crée une BoolQuery vide.ElasticsearchSpecification.constantScore(spec): enveloppe dans une ConstantScoreQuery (utile pourcount()).
3. Interface Specifications (par entité)¶
Chaque entité ES possède une interface *ElasticsearchSpecifications qui déclare des méthodes default retournant ElasticsearchSpecification<QueryBuilder>. Chaque méthode retourne null si le paramètre est vide/null (convention null-safe).
public interface MyEntityElasticsearchSpecifications {
// Filtre par ID exact
default ElasticsearchSpecification<QueryBuilder> entityId(Integer id) {
if (id == null) return null;
return () -> QueryBuilders.termQuery(MyEntityVO.Fields.ID, id);
}
// Filtre par liste d'IDs (inclusion)
default ElasticsearchSpecification<QueryBuilder> includedIds(Integer[] ids) {
if (ArrayUtils.isEmpty(ids)) return null;
return () -> QueryBuilders.termsQuery(MyEntityVO.Fields.ID, (Object[]) ids);
}
// Filtre par liste d'IDs (exclusion)
default ElasticsearchSpecification<QueryBuilder> excludedIds(Integer[] ids) {
if (ArrayUtils.isEmpty(ids)) return null;
return () -> QueryBuilders.boolQuery()
.mustNot(QueryBuilders.termsQuery(MyEntityVO.Fields.ID, (Object[]) ids));
}
// Filtre sur un objet nested (ex: location, type, programme)
default ElasticsearchSpecification<QueryBuilder> locationId(Integer locationId) {
if (locationId == null) return null;
return () -> QueryBuilders.nestedQuery(
MyEntityVO.Fields.LOCATION,
QueryBuilders.termQuery(
StringUtils.doting(MyEntityVO.Fields.LOCATION, IEntity.Fields.ID), locationId),
ScoreMode.None);
}
// Filtre par plage de dates
default ElasticsearchSpecification<QueryBuilder> betweenDates(Date startDate, Date endDate) {
if (startDate == null && endDate == null) return null;
return () -> {
if (startDate != null && endDate != null) {
return QueryBuilders.constantScoreQuery(
QueryBuilders.boolQuery()
.filter(QueryBuilders.rangeQuery(MyEntityVO.Fields.START_DATE).lte(endDate.getTime()))
.filter(QueryBuilders.rangeQuery(MyEntityVO.Fields.END_DATE).gte(startDate.getTime()))
);
} else if (startDate != null) {
return QueryBuilders.rangeQuery(MyEntityVO.Fields.END_DATE).gte(startDate.getTime());
} else {
return QueryBuilders.rangeQuery(MyEntityVO.Fields.START_DATE).lte(endDate.getTime());
}
};
}
// Filtre par date minimale de mise à jour
default ElasticsearchSpecification<QueryBuilder> newerThan(Date minUpdateDate) {
if (minUpdateDate == null) return null;
return () -> QueryBuilders.rangeQuery(MyEntityVO.Fields.UPDATE_DATE).gt(minUpdateDate.getTime());
}
// Filtre par statut
default ElasticsearchSpecification<QueryBuilder> hasStatusIds(List<Integer> statusIds) {
if (CollectionUtils.isEmpty(statusIds)) return null;
return () -> QueryBuilders.termsQuery(MyEntityVO.Fields.STATUS_ID, statusIds);
}
// Recherche textuelle (wildcard/prefix)
Splitter WORDS_SPLITTER = Splitter.on(Pattern.compile("\\s+")).omitEmptyStrings().trimResults();
default ElasticsearchSpecification<QueryBuilder> searchText(String[] searchAttributes, String searchText) {
if (StringUtils.isBlank(searchText)) return null;
// Utiliser ElasticsearchUtils.getEscapedSearchText() pour échapper le texte (regroupe/nettoie les '*')
// Découper sur les espaces uniquement (WORDS_SPLITTER) : chaque "mot" devient une clause must()
// distincte sur le même champ (ET logique) - la ponctuation ('-', '_', '/'...) N'est PAS un
// séparateur ici, car elle doit rester dans le même terme indexé (cf. analyzer = "code_analyzer").
// Construire une BoolQuery avec should + minimumShouldMatch(1)
// Pour chaque attribut, utiliser prefixQuery ou wildcardQuery selon le contexte
return () -> { /* ... */ };
}
}
Conventions :
- Toujours référencer les champs via
MyEntityVO.Fields.FIELD_NAME(jamais de chaînes littérales). - Pour les chemins nested, utiliser
StringUtils.doting(parent, child)(ex:"location.id"). - Les requêtes nested utilisent
ScoreMode.None(pas de scoring sur les sous-documents). - Utiliser
ArrayUtils.isEmpty(),CollectionUtils.isEmpty(),StringUtils.isBlank()pour les gardes null. - Les dates sont comparées en millisecondes via
date.getTime(). WORDS_SPLITTERne doit découper que sur les espaces : le champ texte visé doit être indexé avecanalyzer = "code_analyzer"(pas"standard") pour que la ponctuation reste dans le même terme des deux côtés (indexation ET requête). Mapping et découpage de la recherche doivent rester cohérents.
4. Interface Repository¶
L'interface étend SumarisElasticsearchRepository (interface générique du projet) et l'interface Specifications :
@Repository
@ConditionalOnBean({ElasticsearchRestTemplate.class})
public interface MyEntityElasticsearchRepository
extends SumarisElasticsearchRepository<MyEntityVO, Integer, MyFilterVO, MyFetchOptions>,
MyEntityElasticsearchSpecifications {
// Requêtes déclaratives avec @Query (JSON ES natif)
@Query("{\"bool\": {\"filter\": [{\"term\": {\"entityId\": ?0}}]}}")
List<MyEntityVO> findByEntityId(int entityId, Pageable pageable);
// Méthodes spécifiques (non présentes dans le repository générique)
Optional<Date> findMaxUpdateDate();
}
Règles :
- Annoter
@Repositoryet@ConditionalOnBean({ElasticsearchRestTemplate.class})(activation conditionnelle). - Étendre
SumarisElasticsearchRepository<VO, ID, Filter, FetchOptions>pour bénéficier des méthodes communes (findAll,count,bulkIndex,refresh,recreate,disableReplicas,enableReplicas). - Les requêtes simples peuvent utiliser
@Queryavec du JSON ES natif.
5. Implémentation Repository (RepositoryImpl)¶
Le RepositoryImpl doit étendre SumarisElasticsearchRepositoryImpl et implémenter l'interface Specifications de l'entité :
@Slf4j
public class MyEntityElasticsearchRepositoryImpl
extends SumarisElasticsearchRepositoryImpl<MyEntityVO, Integer, MyFilterVO, MyFetchOptions>
implements MyEntityElasticsearchSpecifications {
public MyEntityElasticsearchRepositoryImpl(SumarisConfiguration configuration,
ElasticsearchRestTemplate template,
ElasticsearchConverter converter) {
super(MyEntityVO.class, MyEntityVO.INDEX, configuration, template);
}
// --- LECTURE ---
// findMaxUpdateDate (tri + limit 1 + source filter)
@Override
public Optional<Date> findMaxUpdateDate() {
checkEnable();
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.matchAllQuery())
.withSorts(SortBuilders.fieldSort(MyEntityVO.Fields.UPDATE_DATE).order(SortOrder.DESC))
.withPageable(PageRequest.of(0, 1))
.withSourceFilter(new FetchSourceFilter(new String[]{MyEntityVO.Fields.UPDATE_DATE}, null))
.build();
SearchHits<MyEntityVO> searchHits = template.search(searchQuery, MyEntityVO.class);
if (!searchHits.hasSearchHits()) return Optional.empty();
return Optional.ofNullable(searchHits.getSearchHit(0).getContent().getUpdateDate());
}
// --- MÉTHODES INTERNES ---
// Conversion FilterVO → ElasticsearchSpecification (composition fluide)
@Override
protected ElasticsearchSpecification<QueryBuilder> toSpecification(@NonNull MyFilterVO filter) {
return ElasticsearchSpecification.bool()
.filter(entityId(filter.getId()))
.filter(includedIds(filter.getIncludedIds()))
.filter(excludedIds(filter.getExcludedIds()))
.filter(locationId(filter.getLocationId()))
.filter(hasStatusIds(filter.getStatusIds()))
.filter(betweenDates(filter.getStartDate(), filter.getEndDate()))
.filter(newerThan(filter.getMinUpdateDate()))
.must(searchText(filter.getSearchAttributes(), filter.getSearchText()));
}
}
Règles d'implémentation :
- Étendre
SumarisElasticsearchRepositoryImpl<VO, ID, Filter, FetchOptions>. - Appeler
super(VO.class, VO.INDEX, configuration, template)dans le constructeur. - Implémenter obligatoirement
toSpecification(Filter)pour définir la logique de filtrage. - Redéfinir les méthodes spécifiques déclarées dans l'interface Repository.
- Utiliser
checkEnable()au début de chaque méthode d'accès à ES. ```
6. Gestion de l'index (réplicas, création, refresh)¶
- Création :
indexOperations.createWithMapping()crée l'index avec le mapping déduit des annotations@Fielddu VO. - Refresh :
indexOperations.refresh()rend les documents indexés immédiatement recherchables. - Réplicas : configurable via
spring.elasticsearch.index.replicas, modifiable à chaud viaUpdateSettingsRequest. - Recréation :
delete()+createWithMapping()pour réinitialiser un index.
7. Résumé des classes utilitaires¶
| Classe | Rôle |
|---|---|
ElasticsearchSpecification<T> |
Interface fonctionnelle, API fluide bool(), filter(), must(), should(), constantScore() |
ElasticsearchSpecificationComposition |
Combinaison interne des specifications (must, filter, should) |
ElasticsearchUtils |
Utilitaires : getEscapedSearchText(), hasNoWildcard(), trimWildcard(), removeTrailingWildcard() |
Pageables |
Conversion Page → Pageable Spring Data |
StreamUtils |
getStream() null-safe sur les itérables |
StringUtils.doting() |
Composition de chemins nested (ex: "location.id") |
8. Checklist pour ajouter un nouveau CRUD Elasticsearch¶
- VO : Annoter le VO existant avec
@Document,@Setting,@Field,@Id. - Specifications : Créer
MyEntityElasticsearchSpecificationsavec les méthodes de filtragedefault. - Repository : Créer
MyEntityElasticsearchRepository extends SumarisElasticsearchRepository<VO, ID, Filter, FetchOptions>+ Specifications. - RepositoryImpl : Créer
MyEntityElasticsearchRepositoryImpl extends SumarisElasticsearchRepositoryImpl<VO, ID, Filter, FetchOptions>implements Specifications avectoSpecification(). - Configuration : Utiliser
@ConditionalOnBean({ElasticsearchRestTemplate.class})et@PostConstruct/@EventListenerpour l'initialisation. - Settings : Réutiliser
settings/whitespace-analyzer.jsonou créer un fichier de settings custom danssrc/main/resources/settings/.