Spring Boot, REST API e ricerca, un’occhiata alle Specification

S

INTRODUZIONE

Ogni volta andiamo a sviluppare una REST API nell’ecosistema Java-SpringBoot che deve esporre dei dati da un database, ci preoccupiamo di come poter fare in modo di interrogare correttamente il nostro archivio e restituire solo quello di cui abbiamo bisogno. Pensiamo ad esempio ad un modulo di ricerca avanzata: abbiamo un oggetto e vogliamo ricercare per più proprietà, non ci basta un semplice filtro che possiamo costruire magari con le Query Method.

Per poter effettuare queste ricerche nel modo più scalare possibile, un’idea è quella di utilizzare Spring Data JPA Specifications.

CHE COSA E’

Si tratta di uno strumento che ci permette di costruire un proprio linguaggio di interrogazione che ci permetta di fare ricerche all’interno di un’entità.

MANI SUL CODICE

Cominciamo a creare un esempio e da questo basarci per potere poi strutturare la nostra ricerca. Per brevità, le varie classi non avranno Getter e Setter ma ci affideremo a Lombok.

// Contatto.java

@Data
@Entity
public class Contatto implements Serialization {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id")
    private Long id;
    @Column(name = "nome")
    private String nome;
    @Column(name = "cognome")
    private String cognome;
    @Column(name = "telefono")
    private String telefono;
    @Column(name = "eta")
    private Integer eta;
}
// ContattoRepository.java

@Repository
public interface ContattoRepository extends JpaRepository<Contatto, Long>, JpaSpecificationExecutor<Contatto> {
}

Una normalissima entità che contiene un id, 3 campi stringa e un campo intero, e la sua repository da usare con Spring Data JPA. Notate come sia implementata un’altra interfaccia rispetto allo standard? Ora cominciamo a costruire i criteri di ricerca.

// SpecSearchCriteria.java

@Data
public class SpecSearchCriteria {
    private String key;
    private SearchOperation operation;
    private Object value;
    private boolean orPredicate;

    public SpecSearchCriteria() {}

    public SpecSearchCriteria(final String key, final SearchOperation operation, final Object value) {
        this(key, SearchOperation.getSimpleOperation(operation.charAt(0)), value);
    }
    public SpecSearchCriteria(final String key, final SearchOperation operation, final Object value) {
        super();
        this.key = key;
        this.operation = operation;
        this.value = value;
    }

    public SpecSearchCriteria(final String orPredicate, final String key, final SearchOperation operation, final Object value) {
        super();
        this.orPredicate = orPredicate != null && orPredicate.equals(SearchOperation.OR_PREDICATE_FLAG);
        this.key = key;
        this.operation = operation;
        this.value = value;
    }

    public SpecSearchCriteria(final String orPredicate, String key, String operation, String prefix, Object value, String suffix) {
        SearchOperation op = SearchOperation.getSimpleOperation(operation.charAt(0));
        if (op != null) {
            if (op == SearchOperation.EQUALITY) { // the operation may be complex operation
                final boolean startWithAsterisk = prefix != null && prefix.contains(SearchOperation.ZERO_OR_MORE_REGEX);
                final boolean endWithAsterisk = suffix != null && suffix.contains(SearchOperation.ZERO_OR_MORE_REGEX);

                if (startWithAsterisk && endWithAsterisk) {
                    op = SearchOperation.CONTAINS;
                } else if (startWithAsterisk) {
                    op = SearchOperation.ENDS_WITH;
                } else if (endWithAsterisk) {
                    op = SearchOperation.STARTS_WITH;
                } else {
                    if ("true".equalsIgnoreCase(value.toString()) || "false".equalsIgnoreCase(value.toString())) {
                        value = Boolean.valueOf(value.toString());
                    }
                }
            }
        }

        this.orPredicate = orPredicate != null && orPredicate.equals(SearchOperation.OR_PREDICATE_FLAG);
        this.key = key;
        this.operation = op;
        this.value = value;
    }
}

Gli strumenti di ricerca sono della classe SpecSearchCriteria, che contiene 3 campi che ci permettono di fare tutto il lavoro, sono:

  • key, nome del campo su cui fare l’operazione (per esempio, nome);
  • operation, nome dell’operazione da eseguire (per esempio, uguale a o maggiore di);
  • value, valore a cui confrontare il dato (per esempio, Mario o 25).

A questi aggiungiamo anche il parametro orPredicate, che ci consente di fare una ricerca con operatore OR, che ci tornerà utile quando vorremo fare una ricerca di questo tipo.

Definita la “specifica”, andiamo a costruire la ricerca vera e propria, dove abbiamo bisogno di chi costruisce la ricerca in base ai criteri voluti.

// SearchOperation.java

public enum SearchOperation {
    EQUALITY,
    NEGATION,
    GREATER_THAN,
    LESS_THAN,
    LIKE,
    STARTS_WITH,
    ENDS_WITH,
    CONTAINS;

    public static final String[] SIMPLE_OPERATION_SET = { ":", "!", ">", "<", "~" };

    public static final String OR_PREDICATE_FLAG = "'";

    public static final String ZERO_OR_MORE_REGEX = "*";

    public static final String OR_OPERATOR = "OR";

    public static final String AND_OPERATOR = "AND";

    public static final String LEFT_PARANTHESIS = "(";

    public static final String RIGHT_PARANTHESIS = ")";

    public static SearchOperation getSimpleOperation(final char input) {
        switch (input) {
            case ':':
                return EQUALITY;
            case '!':
                return NEGATION;
            case '>':
                return GREATER_THAN;
            case '<':
                return LESS_THAN;
            case '~':
                return LIKE;
            default:
                return null;
        }
    }
}
// ContattoSpecification.java
public class ContattoSpecification implements Specification<Contatto> {
    private SpecSearchCriteria criteria;

    public ContattoSpecification(SpecSearchCriteria criteria) {
        this.criteria = criteria;
    }

    @Override
    public Predicate toPredicate(final Root<Contatto> root, final CriteriaQuery<?> query, final CriteriaBuilder builder) {
        switch (criteria.getOperation()) {
            case EQUALITY:
                return builder.equal(root.get(criteria.getKey()), criteria.getValue());
            case NEGATION:
                return builder.notEqual(root.get(criteria.getKey()), criteria.getValue());
            case GREATER_THAN:
                return builder.greaterThan(root.get(criteria.getKey()), criteria.getValue().toString());
            case LESS_THAN:
                return builder.lessThan(root.get(criteria.getKey()), criteria.getValue().toString());
            case LIKE:
                return builder.like(builder.lower(root.get(criteria.getKey())), criteria.getValue().toString().toLowerCase());
            case STARTS_WITH:
                return builder.like(builder.lower(root.get(criteria.getKey())), criteria.getValue().toString().toLowerCase() + "%");
            case ENDS_WITH:
                return builder.like(builder.lower(root.get(criteria.getKey())), "%" + criteria.getValue().toString().toLowerCase());
            case CONTAINS:
                return builder.like(builder.lower(root.get(criteria.getKey())), "%" + criteria.getValue().toString().toLowerCase() + "%");
            default:
                return null;
        }
    }
}

Mentre SearchOperation non è altro che un enum, che descrive le varie operazioni di ricerca, così da poter effettuare il parsing della stringa e capire di che operazione si tratta, ContattoSpecification si occupa invece di costruire il predicato in base ai valori impostati: utilizza l’istanza di SpecSearchCriteria (passata nel costruttore) per poter costruire la query vera e propria all’interno del database.

RICERCHE IN CAMPO

Arrivati a questo punto siamo in grado di fare delle ricerche: è possibile costruire un predicato a partire da una chiave, un’operazione (che viene decodificata tramite l’enum) ed un valore.

Ad esempio:

ContattoSpecification spec1 = new ContattoSpecification(new SpecSearchCriteria("nome", ":", "Mario"));
ContattoSpecification spec2 = new ContattoSpecification(new SpecSearchCriteria("eta", ">", 25));
List<Contatto> results = repository.findAll(Specification.where(spec1).and(spec2));

In questo esempio è stato costruito un primo filtro, che imposta un predicato cercando per nome uguale a Mario (il carattere : in SearchOperation indica un’uguaglianza) e un secondo filtro cercando età maggiore di 25 (il carattere > in SearchOperation indica un confronto di tipo maggiore di).

Un esempio per fare una ricerca con una Like:

ContattoSpecification spec1 = new ContattoSpecification(new SpecSearchCriteria(null, "cognome", ":", "*", "ver", "*")); 
List<Contatto> results = repository.findAll(Specification.where(spec1));

Questa ricerca usa i caratteri * nel valore che, come impostato in SpecSearchCriteria, nel caso li trovasse all’inizio o alla fine del valore, cambierebbe la ricerca da uguaglianza a contiene, inizia con o finisce con, in base a dove questi caratteri sono presenti. In questo esempio, la ricerca sarebbe fatta per cognome che contiene ver. Come si può vedere, la ricerca ti tipo Like è molto più descrittiva, in quanto abbiamo utilizzato il costruttore con più parametri di  SpecSearchCriteria: nulla ieta di fare più costruttori e/o metodi per poter avere il corretto comportamento. Ad esempio, per gestire meglio una Like, si potrebbe introdurre un nuovo costruttore di questo tipo:

public SpecSearchCriteria(final String orPredicate, String key, String operation, Object value) {
    SearchOperation op = SearchOperation.getSimpleOperation(operation.charAt(0));
    if (op != null) {
        if (op == SearchOperation.EQUALITY) { // the operation may be complex operation
            final boolean startWithAsterisk = value.toString().startsWith(SearchOperation.ZERO_OR_MORE_REGEX);
            final boolean endWithAsterisk = value.toString().endsWith(SearchOperation.ZERO_OR_MORE_REGEX);

            if (startWithAsterisk && endWithAsterisk) {
                op = SearchOperation.CONTAINS;
                value = value.toString().substring(SearchOperation.ZERO_OR_MORE_REGEX.length());
                value = value.toString().substring(0, value.toString().length() - SearchOperation.ZERO_OR_MORE_REGEX.length());
            } else if (startWithAsterisk) {
                op = SearchOperation.ENDS_WITH;
                value = value.toString().substring(SearchOperation.ZERO_OR_MORE_REGEX.length());
            } else if (endWithAsterisk) {
                op = SearchOperation.STARTS_WITH;
                value = value.toString().substring(0, value.toString().length() - SearchOperation.ZERO_OR_MORE_REGEX.length());
            } else {
                if ("true".equalsIgnoreCase(value.toString()) || "false".equalsIgnoreCase(value.toString())) {
                    value = Boolean.valueOf(value.toString());
                }
            }
        }
    }

    this.orPredicate = orPredicate != null && orPredicate.equals(SearchOperation.OR_PREDICATE_FLAG);
    this.key = key;
    this.operation = op;
    this.value = value;
}

E, conseguentemente, modificare la ricerca precedente:

ContattoSpecification spec1 = new ContattoSpecification(new SpecSearchCriteria(null, "cognome", ":", "*ver*"));
List<Contatto> results = repository.findAll(Specification.where(spec1));

RICERCHE DA REST

La nostra volontà è però quella di esporre una REST API che deve essere istruita per poter poi fare le ricerche. Quindi dobbiamo preoccuparci di avere un parametro che contenga una stringa con la ricerca da effettuare e da qui costruire poi la lista di predicati. Chiaramente è possibile sbizzarrirsi e costruirsi il proprio linguaggio, ma immaginiamo di dover mettere delle virgole a dividere le varie ricerche.

// ContattoController.java

@RestController
@RequestMapping("/api")
@Transactional
public class ContattoController {
    private ContattoRepository contattoRepository;

    @GetMapping("/contatto")
    public ResponseEntity<List<Contatto>> getContatto(
        @RequestParam(value = "search", required = false) String search
    ) {
        final List<Contatto> retVal;

        if (search != null) {
            ContattoSpecificationBuilder builder = new ContattoSpecificationBuilder();
            String operationSetExper = Joiner.on("|").join(SearchOperation.SIMPLE_OPERATION_SET);
            Pattern pattern = Pattern.compile("(\\p{Punct}?)(\\w+?)(" + operationSetExper + ")(\\p{Punct}?)(\\w+?)(\\p{Punct}?),");
            Matcher matcher = pattern.matcher(search + ",");
            while (matcher.find()) {
                builder.with(matcher.group(1), matcher.group(2), matcher.group(3), matcher.group(5), matcher.group(4), matcher.group(6));
            }

            Specification<Contatto> spec = builder.build();
            retVal= contattoRepository.findAll(spec);
        } else {
            retVal = contattoRepository.findAll()
        }

        return new ResponseEntity<>(retVal, HttpStatus.OK);
    }
}
// ContattoSpecificationBuilder.java

public class ContattoSpecificationBuilder {
    private final List<SpecSearchCriteria> params;

    public ContattoSpecificationBuilder() {
        params = new ArrayList<>();
    }

    public final ContattoSpecificationBuilder with(
        final String key,
        final String operation,
        final Object value,
        final String prefix,
        final String suffix
    ) {
        return with(null, key, operation, value, prefix, suffix);
    }

    public final ContattoSpecificationBuilder with(ContattoSpecification spec) {
        params.add(spec.getCriteria());
        return this;
    }

    public final ContattoSpecificationBuilder with(SpecSearchCriteria criteria) {
        params.add(criteria);
        return this;
    }

    public final ContattoSpecificationBuilder with(
        final String orPredicate,
        final String key,
        final String operation,
        Object value,
        final String prefix,
        final String suffix
    ) {
        params.add(new SpecSearchCriteria(orPredicate, key, operation, prefix, value, suffix));
        return this;
    }

    public Specification<Contatto> build() {
        if (params.size() == 0) return null;

        Specification<Contatto> result = new ContattoSpecification(params.get(0));

        for (int i = 1; i < params.size(); i++) {
            result =
                params.get(i).isOrPredicate()
                    ? Specification.where(result).or(new ContattoSpecification(params.get(i)))
                    : Specification.where(result).and(new ContattoSpecification(params.get(i)));
        }

        return result;
    }
}

Con la classe ContattoSpecificationBuilder non facciamo altro che costruire un insieme di predicati, che vengono impostati tramite la variabile search di ContattoController. La regex presente non fa altro che dividere la stringa inserita nel pattern specifico:

  • un carattere, eventualmente, uguale all’apice per istruire una ricerca OR;
  • una stringa di testo che contiene la chiave da cercare;
  • un’operazione, fra il set di operazioni disponibili;
  • un carattere, eventualmente, uguale all’asterisco * per definire che il valore può essere contenuto alla fine del ampo;
  • una stringa di testo che contiene il valore da cercare;
  • un carattere, eventualmente, uguale all’asterisco * per definire che il valore può essere contenuto all’inizio del campo.

Qualche esempio pratico, ammettendo che la nostra applicazione sia avviata in locale sulla porta 8080:

  • http://localhost:8080/api/contatto?search=telefono:0212345678 – la stringa di ricerca equivale a “trova tutti i contatti che hanno come numero di telefono 0212345678;
  • http://localhost:8080/api/contatto?search=nome:Mario,age>25 – la stringa di ricerca equivale a “trova tutti i contatti che si chiamano Mario e che hanno come età maggiore di 25;
  • http://localhost:8080/api/contatto?search=cognome:*ossi – la stringa di ricerca equivale a “trova tutti i contatti che hanno il cognome che finiscono per ossi;
  • http://localhost:8080/api/contatto?search=cognome:*anni*,nome!Renzo – la stringa di ricerca equivale a “trova tutti i contatti che nel cognome contengono anni e che non hanno nome uguale a Renzo;
  • http://localhost:8080/api/contatto?search=eta>40,’eta<25 – la stringa equivale a “trova tutti i contatti che hanno età maggiore di 40 oppure età inferiore a 25 (notare l’apice prima del secondo parametro);
  • http://localhost:8080/api/contatto?search=eta<40,nome:Mario,cognome:*carr* – la stringa equivale a “trova tutti i contatti che hanno età inferiore a 40, si chiamano Mario e i cognomi contengono carr.

 

A proposito di me

Mario Biagioni
Di Mario Biagioni

I nostri Partner

Gli articoli più letti

Articoli recenti

Commenti recenti