Tutoriel pour apprendre les Stream Java grâce au SQL

Gokan EKINCI

Ce tutoriel s'adresse aux développeurs qui souhaitent découvrir (ou redécouvrir) l'API stream introduite dans Java 8 grâce au langage SQL.

Commentez

- Titre : Tutoriel pour apprendre les Stream Java grâce au SQL

- Auteur : Gokan EKINCI

- Parution : 6 août 2017

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2017 Gokan EKINCI. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Prérequis

- II. Les bases

- III. Les fonctions d'agrégat

- III-A. MIN : Plus jeune âge

- III-B. MAX : Plus haut salaire

- III-C. AVG : Moyenne d'âge des employés

- III-D. COUNT : Nombre de femmes

- III-E. SUM : Somme des salaires avec 21,7 % de taxe

- IV. La syntaxe Reduce

- IV-A. MIN : Plus jeune âge

- IV-B. MAX : Plus haut salaire

- IV-C. AVG : Moyenne d'âge des employés

- IV-D. COUNT : Nombre de femmes

- IV-E. SUM : Somme des salaires avec 21,7 % de taxe

- V. Le regroupement

- V-A. La syntaxe GROUP BY

- V-B. La syntaxe DISTINCT

- V-C. Exemple avec MAX

- V-D. Exemple avec AVG

- V-E. Exemple avec COUNT

- VI. La jointure cartésienne

- VII. La jointure interne

- VIII. Les limites de l'API Stream

- IX. Remerciements

I - Prérequis

Vous pouvez utiliser l'IDE que vous souhaitez pour suivre ce tutoriel. Le code source des exemples présentés dans ce tutoriel est disponible dans ce repository Git, et plus particulièrement dans cette classe.

Les outils dont vous aurez besoin pour suivre ce tutoriel sont : JDK 8 d'Oracle, Maven 3.

N'hésitez pas à lire le tutoriel Maven 3 si vous souhaitez en apprendre plus sur ce dernier.

Avoir un SGBDR installé sur votre machine est un plus pour tester les exemples SQL. Si vous souhaitez tester les requêtes SQL qui vont suivre, vous pouvez exécuter le script ci-dessous dans une base de test :

CREATE TABLE employees (
gender    VARCHAR(100),
age        INTEGER,
salary    INTEGER,
name    VARCHAR(100)
);

INSERT INTO employees (gender, age, salary, name)
VALUES
('M', 40, 9800, 'Bruce WAYNE'),
('M', 33, 1500, 'Clark KENT'),
('M', 23, 4500, 'Barry ALLEN'),
('M', 19, 2400, 'Wally WEST'),
('M', 28, 8000, 'Hal JORDAN'),
('M', 35, 9500, 'Oliver QUEEN'),
('M', 42, 4700, 'Ray PALMER'),
('M', 22, 3600, 'Victor Stone'),
('M', 27, 1500, 'John CONSTANTINE'),
('M', 65, 2600, 'J''onn J''ONZZ'),
('M', 28, 1400, 'Arthur CURRY'),
('M', 25, 2500, 'Dick GRAYSON'),
('F', 65, 6000, 'Diana PRINCE'),
('F', 24, 2500, 'Barbara GORDON'),
('F', 36, 1800, 'Selina KYLE'),
('F', 30, 2400, 'Pamela ISLEY'),
('F', 28, 2400, 'Harleen QUINZEL'),
('F', 29, 2000, 'Zatanna ZATARA');

Voici un aperçu des données Java :

public static List<Employee> employees() {
return new ArrayList<Employee>(){{
add(Employee.builder().gender("M").age(40).salary(9800).name("Bruce WAYNE").build());
add(Employee.builder().gender("M").age(33).salary(1500).name("Clark KENT").build());
add(Employee.builder().gender("M").age(23).salary(4500).name("Barry ALLEN").build());
add(Employee.builder().gender("M").age(19).salary(2400).name("Wally WEST").build());
add(Employee.builder().gender("M").age(28).salary(8000).name("Hal JORDAN").build());
add(Employee.builder().gender("M").age(35).salary(9500).name("Oliver QUEEN").build());
add(Employee.builder().gender("M").age(42).salary(4700).name("Ray PALMER").build());
add(Employee.builder().gender("M").age(22).salary(3600).name("Victor Stone").build());
add(Employee.builder().gender("M").age(27).salary(1500).name("John CONSTANTINE").build());
add(Employee.builder().gender("M").age(65).salary(2600).name("J'onn J'ONZZ").build());
add(Employee.builder().gender("M").age(28).salary(1400).name("Arthur CURRY").build());
add(Employee.builder().gender("M").age(25).salary(2500).name("Dick GRAYSON").build());
add(Employee.builder().gender("F").age(65).salary(6000).name("Diana PRINCE").build());
add(Employee.builder().gender("F").age(24).salary(2500).name("Barbara GORDON").build());
add(Employee.builder().gender("F").age(36).salary(1800).name("Selina KYLE").build());
add(Employee.builder().gender("F").age(30).salary(2400).name("Pamela ISLEY").build());
add(Employee.builder().gender("F").age(28).salary(2400).name("Harleen QUINZEL").build());
add(Employee.builder().gender("F").age(29).salary(2000).name("Zatanna ZATARA").build());
}};
}

II - Les bases

L'API Stream est inspirée des langages fonctionnels, elle permet de réaliser diverses opérations sur des ensembles en utilisant le fluent pattern (ou « chaînage de méthodes » en français). Pour notre cas, les collections (ou « listes » si vous préférez) représenteront ces ensembles.

L'API Stream ne permet pas de réaliser des choses qu'il n'était pas possible de faire avant, par contre elle améliore grandement la lisibilité du code (à moins d'en abuser bien évidemment). Voici deux algorithmes qui permettent de récupérer les trois employés (homme) les plus riches de l'entreprise :

Sans l'API Stream :

List<Employee> temp = new ArrayList<>();
for (Employee employee : employees()) {
if ("M".equals(employee.getGender())) { // WHERE gender = 'M'
temp.add(employee);
}
}

Collections.sort(temp, (e1, e2) -> e2.getSalary() - e1.getSalary()); // ORDER BY salary DESC
int limit = (temp.size() > 0 && temp.size() <= 3) ? temp.size() : 3;
List<Employee> result = (
temp.isEmpty()) ? Collections.emptyList() : temp.subList(0, limit); // LIMIT X

// Print
System.out.println("Salary | Name");
result.forEach(employee ->
System.out.println(
String.format("%s   | %s", employee.getSalary(), employee.getName())
)
);

Avec l'API Stream :

List<Employee> result = employees().stream()
.filter(employee -> "M".equals(employee.getGender())) // WHERE gender = 'M'
.sorted((e1, e2) -> e2.getSalary() - e1.getSalary())  // ORDER BY salary DESC
.limit(3) // LIMIT X
.collect(Collectors.toList());

// Print
System.out.println("Salary | Name");
result.forEach(employee ->
System.out.println(
String.format("%s   | %s", employee.getSalary(), employee.getName())
)
);

Pour les exemples qui vont suivre, nous allons d'un côté réaliser des opérations sur une table en SQL, et de l'autre sur une liste d'objets en Java. Reprenons l'exemple précédent :

en SQL :

SELECT *
FROM employees
WHERE gender = 'M'
ORDER BY salary DESC
LIMIT 3;

avec l'API Stream en Java :

List<Employee> result = employees().stream()
.filter(employee -> "M".equals(employee.getGender())) // WHERE gender = 'M'
.sorted((e1, e2) -> e2.getSalary() - e1.getSalary())  // ORDER BY salary DESC
.limit(3)                                             // LIMIT X
.collect(Collectors.toList());

Grâce à cet exemple de base, nous avons vu comment filtrer, ordonner et limiter le résultat.

L'exemple ci-dessous permet d'ajouter plusieurs critères pour ordonner le résultat :

en SQL :

SELECT *
FROM employees
ORDER BY
gender ASC,
salary ASC,
name ASC;

avec l'API Stream en Java :

List<Employee> result = employees().stream()
.sorted(
Comparator.comparing(Employee::getGender) // ORDER BY gender ASC
.thenComparing(Employee::getSalary)   // , salary ASC
.thenComparing(Employee::getName)     // , name ASC
)
.collect(Collectors.toList());

III - Les fonctions d'agrégat

Les fonctions d'agrégat permettent, comme leur nom indique, d'agréger un ensemble. Voici quelques opérations d'agrégat sur notre liste d'employés.

III-A - MIN : Plus jeune âge

En SQL :

SELECT
MIN(age) AS youngestAge
FROM employees;

Avec l'API Stream en Java :

Optional<Integer> youngestAge = employees().stream()
.map(Employee::getAge)
.min(Integer::compare); // MIN(age)

III-B - MAX : Plus haut salaire

En SQL :

SELECT
MAX(salary) AS highestSalary
FROM employees;

Avec l'API Stream en Java :

Optional<Integer> highestSalary =  employees().stream()
.map(Employee::getSalary)
.max(Integer::compare); // MAX(salary)

III-C - AVG : Moyenne d'âge des employés

En SQL :

SELECT
AVG(age) AS averageAge
FROM employees;

Avec l'API Stream en Java :

OptionalDouble averageAge = employees().stream()
.mapToInt(Employee::getAge)
.average(); // AVG(age)

III-D - COUNT : Nombre de femmes

En SQL :

SELECT
COUNT(*) AS womenCount
FROM employees WHERE gender = 'F';

Avec l'API Stream en Java :

long womenCount = employees().stream()
.filter(employee -> "F".equals(employee.getGender())) // WHERE gender = 'F'
.count(); // COUNT(*)

III-E - SUM : Somme des salaires avec 21,7 % de taxe

En SQL :

SELECT
SUM(salary) * 1.217 AS salarySumWithTaxes
FROM employees;

Avec l'API Stream en Java :

double salarySumWithTaxes = employees().stream()
.mapToDouble(Employee::getSalary)
.sum() * 1.217; // SUM(salary) * 1.217

IV - La syntaxe Reduce

Il est possible de réaliser l'ensemble des opérations effectuées dans la partie III manuellement grâce à la méthode de base reduce().

La méthode reduce() possède plusieurs signatures de méthode. Nous en étudierons deux :

- Optional<T> reduce(BinaryOperator<T> accumulator) ;

- T reduce(T valeurInitiale, BinaryOperator<T> accumulator).

La méthode reduce() prend en paramètre un lambda avec deux paramètres, le premier paramètre du lambda représente le résultat des calculs effectués précédemment et le second paramètre le nouvel élément du stream, l'implémentation de votre lambda contiendra donc l'opération que vous comptez effectuer entre le résultat de vos précédentes opérations et le nouvel élément du stream.

À savoir

- La première méthode retourne un optional. Si vous utilisez la première méthode et que votre stream ne contient aucun élément, vous obtiendrez un optional vide.

- La deuxième méthode retourne directement votre résultat. Le premier paramètre de la deuxième méthode représente la valeur initiale de votre stream, et ce sera la valeur retournée si votre stream ne contient aucun élément.

- Les deux méthodes ont un intérêt différent, par exemple, si vous souhaitez relever le plus haut salaire mensuel à partir de 10 000 €, mais que votre stream ne contient aucun salaire aussi élevé, il serait peut-être préférable d'utiliser la première méthode et de retourner un optional vide plutôt que 0. Par contre, si vous souhaitez retourner le nombre de femmes et qu'il n'y a pas de femme dans l'entreprise, il serait peut-être préférable d'utiliser la deuxième et de retourner 0 plutôt qu'un optional vide.

- Si votre stream ne contient qu'un seul élément, le résultat de votre reduce sera cet élément.

- Si vous utilisez la première méthode, lors de la première opération, le premier paramètre de votre lambda vaut le premier élément du stream et le second paramètre vaut le second élément du stream. Si vous utilisez la deuxième méthode, lors de la première opération, le premier paramètre de votre lambda vaut l'opération réalisée entre la valeur initiale de votre stream et le premier élément du stream. Lors des autres passages, le premier paramètre de votre lambda vaudra le résultat de vos précédentes opérations, et le second paramètre le nouvel élément du stream.

IV-A - MIN : Plus jeune âge

Optional<Integer> youngestAge = employees().stream()
.map(Employee::getAge)
.reduce((a, b) -> (a < b) ? a : b); // MIN(age)

IV-B - MAX : Plus haut salaire

Optional<Integer> highestSalary =  employees().stream()
.map(Employee::getSalary)
.reduce((a, b) -> (a > b) ? a : b); // MAX(age)

IV-C - AVG : Moyenne d'âge des employés

// AVG => SUM() / COUNT(*)
AtomicInteger count = new AtomicInteger(1);
OptionalDouble sumAge = employees().stream()
.mapToDouble(Employee::getAge)
.reduce((a, b) -> {
count.incrementAndGet();
return a + b;
});
OptionalDouble averageAge = sumAge.isPresent() ?
OptionalDouble.of(sumAge.getAsDouble() / count.get()) : OptionalDouble.empty();

IV-D - COUNT : Nombre de femmes

long womenCount = employees().stream()
.filter(employee -> "F".equals(employee.getGender())) // WHERE gender = 'F'
.map(e -> 1L)
.reduce(0L, (a, b) -> a + b); // COUNT(*)

IV-E - SUM : Somme des salaires avec 21,7 % de taxe

double salarySumWithTaxes = employees().stream()
.map(Employee::getSalary)
.reduce(0, (a, b) -> (a + b)) * 1.217; // SUM(salary) * 1.217

V - Le regroupement

Les streams permettent de réaliser des regroupements.

V-A - La syntaxe GROUP BY

En SQL :

SELECT salary
FROM employees
GROUP BY salary;

Avec l'API Stream en Java :

Map<Integer, List<Employee>> genders2 = employees().stream()
.collect(Collectors.groupingBy(Employee::getSalary));        // GROUP BY

// Print
System.out.println(String.format("Genders: %s",     genders2.entrySet().stream()
.map(Map.Entry::getKey)
.map(Object::toString)
.collect(Collectors.joining(", "))
));

V-B - La syntaxe DISTINCT

En SQL :

SELECT DISTINCT salary
FROM employees;

Avec l'API Stream en Java :

List<Integer> genders1 = employees().stream()
.map(Employee::getSalary)
.distinct()        // DISTINCT
.collect(Collectors.toList());

// Print
System.out.println(String.format("Genders: %s",
genders1.stream()
.map(Object::toString)
.collect(Collectors.joining(", "))
));

V-C - Exemple avec MAX

En SQL :

SELECT gender, MAX(salary) AS salary
FROM employees
GROUP BY gender;

Avec l'API Stream en Java :

Map<String, Optional<Integer>> result = employees().stream()
.collect(Collectors.groupingBy(
Employee::getGender, // GROUP BY gender
Collectors.mapping(Employee::getSalary, Collectors.maxBy(Integer::compare)) // MAX(salary)
));

// Print
result.entrySet().forEach(entry ->
System.out.println(String.format("Gender: %s | Max Salary: %s", entry.getKey(), entry.getValue()))
);

V-D - Exemple avec AVG

En SQL :

SELECT gender, AVG(salary) AS salary
FROM employees
GROUP BY gender;

Avec l'API Stream en Java :

Map<String, Double> result = employees().stream()
.collect(Collectors.groupingBy(
Employee::getGender, // GROUP BY gender
Collectors.averagingDouble(Employee::getSalary) // AVG(salary)
));

// Print
result.entrySet().forEach(entry ->
System.out.println(String.format("Gender: %s | Average Salary: %s", entry.getKey(), entry.getValue()))
);

V-E - Exemple avec COUNT

En SQL :

SELECT gender, COUNT(*) AS count
FROM employees
GROUP BY gender;

Avec l'API Stream en Java :

Map<String, Long> result = employees().stream()
.collect(Collectors.groupingBy(
Employee::getGender,  // GROUP BY gender
Collectors.counting() // COUNT(*)
));

// Print
result.entrySet().forEach(entry ->
System.out.println(String.format("Gender: %s | Count: %s", entry.getKey(), entry.getValue()))
);

VI - La jointure cartésienne

Les streams permettent de réaliser des jointures cartésiennes.

En SQL :

SELECT t1.e AS x, t2.e AS y, t3.e AS z
FROM t1
CROSS JOIN t2
CROSS JOIN t3;

Avec l'API Stream en Java :

String[] t1 = {"A", "B", "C"};
String[] t2 = {"B", "C", "D"};
String[] t3 = {"C", "F", "E"};

// CROSS JOIN
List<Container> list = Stream.of(t1)
.flatMap(x -> Arrays.stream(t2)            // t1 CROSS JOIN t2
.flatMap(y -> Arrays.stream(t3)        // t2 CROSS JOIN t3
.map(z -> new Container(x, y, z))
)
)
.collect(Collectors.toList());

// Print
list.forEach(System.out::println);

VII - La jointure interne

Les streams permettent de réaliser des jointures internes.

En SQL :

SELECT t1.e AS x, t2.e AS y, t3.e AS z
FROM t1
INNER JOIN t2
ON t1.e = t2.e
INNER JOIN t3
ON t2.e = t3.e;

Avec l'API Stream en Java :

String[] t1 = {"A", "B", "C"};
String[] t2 = {"B", "C", "D"};
String[] t3 = {"C", "F", "E"};

// INNER JOIN
List<Container> list = Stream.of(t1)
.flatMap(x -> Arrays.stream(t2)            // t1 INNER JOIN t2
.filter(y -> Objects.equals(x, y))        // ON t1.e = t2.e
.flatMap(y -> Arrays.stream(t3)        // t2 INNER JOIN t3
.filter(z -> Objects.equals(y, z))    // ON t2.e = t3.e
.map(z -> new Container(x, y, z))
)
).collect(Collectors.toList());

// Print
list.forEach(System.out::println);

VIII - Les limites de l'API Stream

L'API stream permet de réaliser avec une grande aisance des opérations sur une collection, mais son utilisation possède aussi des limites lorsqu'il s'agit d'effectuer des jointures entre plusieurs collections. En effet, la syntaxe peut porter à confusion au fur et à mesure qu'une liste est ajoutée dans la chaîne d'opérations, de plus, les streams ne sont pas adaptés pour réaliser des jointures externes (cf. : LEFT JOIN / RIGHT JOIN / FULL JOIN).

Quoi de mieux que du SQL pour faire du SQL ? Et si nous essayions de manipuler des collections d'objets Java directement en SQL ? C'est ce que permet par exemple de faire l'API InMemorySQL du projet dbms-replication. Voici la dépendance Maven pour l'importer dans votre projet :

<dependency>
<groupId>com.github.gokan-ekinci</groupId>
<artifactId>dbms-replication</artifactId>
<version>1.0</version>
</dependency>

Si nous reprenons l'exemple de la jointure cartésienne, cela donne :

List<Tuple> t1 = tupleInit("A", "B", "C");
List<Tuple> t2 = tupleInit("B", "C", "D");
List<Tuple> t3 = tupleInit("C", "D", "E");

String request = "SELECT t1.e AS x, t2.e AS y, t3.e AS z FROM t1 CROSS JOIN t2 CROSS JOIN t3";
new InMemorySQL()
.add(Tuple.class, t1)
.add(Tuple.class, t2)
.add(Tuple.class, t3)
.executeQuery(Container.class, request)
.forEach(System.out::println);

Pour la jointure interne, cela donne :

List<Tuple> t1 = tupleInit("A", "B", "C");
List<Tuple> t2 = tupleInit("B", "C", "D");
List<Tuple> t3 = tupleInit("C", "D", "E");

String request = "SELECT t1.e AS x, t2.e AS y, t3.e AS z " +
"FROM t1 " +
"INNER JOIN t2 " +
"ON t1.e = t2.e " +
"INNER JOIN t3 " +
"ON t2.e = t3.e";
new InMemorySQL()
.add(Tuple.class, t1)
.add(Tuple.class, t2)
.add(Tuple.class, t3)
.executeQuery(Container.class, request)
.forEach(System.out::println);

IX - Remerciements

Je tiens à remercier Mickael Baron pour la relecture technique et ClaudeLELOUP pour la relecture orthographique.