Skip to content

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: run npm run site:sync-rules to 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.Long pour les dates (stockées en millisecondes epoch).
  • FieldType.Text avec fielddata = true pour les champs texte triables/agrégables.
  • FieldType.Nested pour les objets imbriqués (LocationVO, ReferentialVO, ProgramVO, etc.).
  • index = false pour les champs non recherchables (comments, etc.).
  • searchAnalyzer = "whitespace_analyzer", analyzer = "code_analyzer" pour tout champ texte recherché via searchText() (noms, codes/identifiants) : voir "Choix de l'analyzer" ci-dessous.

Choix de l'analyzer (important) :

  • searchText() (section 3) construit des wildcardQuery/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" (tokenizer code_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_analyzer et whitespace_analyzer sont fonctionnellement identiques (les deux ne découpent que sur les espaces) ; code_analyzer est le nom conventionnel pour l'analyzer d'indexation, whitespace_analyzer pour le searchAnalyzer (utilisé seulement par les requêtes analysées type match, pas par wildcardQuery/prefixQuery).

Règles pour l'index :

  • createIndex = false : l'index est créé programmatiquement dans le RepositoryImpl.
  • Le nom d'index est préfixé via SpEL : #{@environment.getProperty('spring.elasticsearch.index.prefix', '')}.
  • Le fichier de settings (whitespace-analyzer.json) est dans src/main/resources/settings/.
  • ⚠️ Changer l'analyzer d'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 pour count()).

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_SPLITTER ne doit découper que sur les espaces : le champ texte visé doit être indexé avec analyzer = "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 @Repository et @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 @Query avec 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 @Field du VO.
  • Refresh : indexOperations.refresh() rend les documents indexés immédiatement recherchables.
  • Réplicas : configurable via spring.elasticsearch.index.replicas, modifiable à chaud via UpdateSettingsRequest.
  • 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

  1. VO : Annoter le VO existant avec @Document, @Setting, @Field, @Id.
  2. Specifications : Créer MyEntityElasticsearchSpecifications avec les méthodes de filtrage default.
  3. Repository : Créer MyEntityElasticsearchRepository extends SumarisElasticsearchRepository<VO, ID, Filter, FetchOptions> + Specifications.
  4. RepositoryImpl : Créer MyEntityElasticsearchRepositoryImpl extends SumarisElasticsearchRepositoryImpl<VO, ID, Filter, FetchOptions> implements Specifications avec toSpecification().
  5. Configuration : Utiliser @ConditionalOnBean({ElasticsearchRestTemplate.class}) et @PostConstruct/@EventListener pour l'initialisation.
  6. Settings : Réutiliser settings/whitespace-analyzer.json ou créer un fichier de settings custom dans src/main/resources/settings/.