Typage dynamique en SQL | Fusée


Comme Peter Bailis l’a dit dans son Publier, interroger des données non structurées à l’aide de SQL est un processus pénible. De plus, les développeurs préfèrent souvent les langages de programmation dynamiques, donc interagir avec le système de type strict de SQL est un obstacle.

Chez Rockset, nous avons construit la première plate-forme de données SQL sans schéma. Dans cet article et quelques autres qui suivent, nous aimerions vous présenter notre approche. Nous vous expliquerons nos motivations, quelques exemples et quelques défis techniques intéressants que nous avons découverts lors de la construction de notre système.

Beaucoup d’entre nous chez Rockset sont fans du langage de programmation Python. Nous aimons son pragmatisme, son attitude pragmatique « Il devrait y avoir une – et de préférence une seule – manière évidente de le faire » (Le Zen de Python), et, surtout, son système de type simple mais puissant.

Python est fortement et dynamiquement tapé :

  • Fortcar les valeurs ont un type spécifique (ou None), et les valeurs de types incompatibles ne sont pas automatiquement converties entre elles. Les chaînes sont des chaînes, les nombres sont des nombres, les booléens sont des booléens, et ils ne se mélangent que de manière claire et bien définie. Contrairement à JavaScript, qui est faiblement tapé. JavaScript permet (par exemple) l’addition et la comparaison entre des nombres et des chaînes, avec des résultats déroutants.
  • Dynamiquecar les variables acquièrent des informations de type au moment de l’exécution et la même variable peut, à différents moments, contenir des valeurs de type différent. a = 5 fera a tenir un nombre entier ; une mission ultérieure a="hello" fera a tenir une ficelle. Contrairement à Java et C, qui sont statiquement tapé. Les variables doivent être déclarées et ne peuvent contenir que des valeurs du type spécifié lors de la déclaration.

Bien sûr, aucune langue ne tombe parfaitement dans l’une de ces catégories, mais elles forment néanmoins une classification utile pour une compréhension de haut niveau des systèmes de types.

La plupart des bases de données SQL, en revanche, sont fortement et statiquement tapé. Les valeurs d’une même colonne ont toujours le même type, et le type est défini au moment de la création de la table et est difficile à modifier ultérieurement.

Qu’est-ce qui ne va pas avec le typage statique de SQL ?

Cette inadéquation d’impédance entre les langages à typage dynamique et le typage statique de SQL a éloigné le développement des bases de données SQL vers les systèmes NoSQL. Il est plus facile de créer des applications sur des systèmes NoSQL, surtout au début, avant que le modèle de données ne se stabilise. Bien sûr, l’abandon des bases de données SQL traditionnelles signifie également que vous avez également tendance à perdre des index efficaces et la possibilité d’effectuer des requêtes et des jointures complexes.

De plus, les ensembles de données modernes se présentent souvent sous une forme semi-structurée (JSON, XML, YAML) et ne suivent pas un schéma statique bien défini. Il faut souvent créer un pipeline de prétraitement pour déterminer le schéma correct à utiliser, nettoyer les données d’entrée et les transformer pour qu’elles correspondent au schéma, et ces pipelines sont fragiles et sujets aux erreurs.

De plus, SQL ne gère traditionnellement pas très bien les données profondément imbriquées (tableaux JSON de tableaux d’objets contenant des tableaux…). Le pipeline de données doit ensuite aplatir les données, ou du moins les fonctionnalités auxquelles il faut accéder rapidement. Cela ajoute encore plus de complexité au processus.

Quelle est l’alternative ?

Et si nous essayions de construire une base de données SQL typée dynamiquement à partir de zéro, sans sacrifier la puissance de SQL ?

Le modèle de données de Rockset est similaire à JSON : les valeurs sont soit

  • scalaires (nombres, booléens, chaînes, etc.)
  • tableaux, contenant n’importe quel nombre de valeurs arbitraires
  • cartes (que, empruntant à JSON, nous appelons « objets »), mappant des clés de chaîne à des valeurs arbitraires

Nous étendons le modèle de données de JSON pour prendre également en charge d’autres types scalaires (tels que les types liés à la date et à l’heure), mais plus à ce sujet dans un prochain article.

Surtout, les documents n’ont pas besoin d’avoir les mêmes champs. Ce n’est pas grave si un champ apparaît dans (disons) 10 % des documents ; les requêtes se comporteront comme si ce champ était NULL dans les 90 % restants.

Différents documents peuvent avoir des valeurs de différents types dans le même champ. C’est important; de nombreux ensembles de données réels ne sont pas propres et vous trouverez (par exemple) des codes postaux stockés sous forme d’entiers dans une partie de l’ensemble de données et stockés sous forme de chaînes dans d’autres parties. Rockset vous permettra d’ingérer et d’interroger de tels documents. Selon la requête, les valeurs de types inattendus peuvent être ignorées, traitées comme NULLou signaler des erreurs.

Il y aura une légère dégradation des performances causée par la nature dynamique du système de type. Il est plus facile d’écrire du code efficace si vous savez que vous traitez un grand nombre d’entiers, par exemple, plutôt que d’avoir à vérifier chaque valeur. Mais, en pratique, les données de type vraiment mixte sont rares – peut-être y aura-t-il quelques chaînes aberrantes dans une colonne d’entiers, de sorte que les vérifications de type peuvent en pratique être retirées des chemins de code critiques. C’est, à un niveau élevé, similaire à ce que font les compilateurs juste-à-temps pour les langages dynamiques aujourd’hui : oui, les variables peuvent changer de type au moment de l’exécution, mais ce n’est généralement pas le cas, il vaut donc la peine d’optimiser pour le cas courant.

Les bases de données relationnelles traditionnelles sont nées à une époque où le stockage était coûteux, elles ont donc optimisé la représentation de chaque octet sur le disque. Heureusement, ce n’est plus le cas, ce qui ouvre la porte à des formats de représentation internes qui privilégient les fonctionnalités et la flexibilité à l’utilisation de l’espace, ce que nous pensons être un compromis valable.

Un exemple simple

J’aimerais vous présenter un exemple simple de la façon dont vous pouvez utiliser des types dynamiques dans Rockset SQL. Nous commencerons par un ensemble de données trivialement petit, composé d’informations biographiques de base pour six personnes imaginaires, fournies sous forme de fichier avec un document JSON par ligne (qui est un format que Rockset prend en charge de manière native) :

{"name": "Tudor", "age": 40, "zip": 94542}
{"name": "Lisa", "age": 21, "zip": "91126"}
{"name": "Hana"}
{"name": "Igor", "zip": 94110.0}
{"name": "Venkat", "age": 35, "zip": "94020"}
{"name": "Brenda", "age": 44, "zip": "90210"}

Comme c’est souvent le cas avec les données du monde réel, cet ensemble de données n’est pas propre. Certains documents manquent certains champs, et le champ du code postal (qui devrait être une chaîne) est un int pour certains documents, et un float pour les autres.

Rockset ingère cet ensemble de données sans problème :

$ rock add tudor_example1 /tmp/example_docs
 COLLECTION       ID                                      STATUS   ERROR
tudor_example1   3e117812-4b50-4e55-b7a6-de03274fc7df-1   ADDED    None
tudor_example1   3e117812-4b50-4e55-b7a6-de03274fc7df-2   ADDED    None
tudor_example1   3e117812-4b50-4e55-b7a6-de03274fc7df-3   ADDED    None
tudor_example1   3e117812-4b50-4e55-b7a6-de03274fc7df-4   ADDED    None
tudor_example1   3e117812-4b50-4e55-b7a6-de03274fc7df-5   ADDED    None
tudor_example1   3e117812-4b50-4e55-b7a6-de03274fc7df-6   ADDED    None

et nous pouvons voir qu’il a conservé les types originaux des champs :

$ rock sql
> describe tudor_example1;
+-----------+---------------+---------+--------+
| field     | occurrences   | total   | type   |
|-----------+---------------+---------+--------|
| ['_meta'] | 6             | 6       | object |
| ['age']   | 4             | 6       | int    |
| ['name']  | 6             | 6       | string |
| ['zip']   | 1             | 6       | float  |
| ['zip']   | 1             | 6       | int    |
| ['zip']   | 3             | 6       | string |
+-----------+---------------+---------+--------+

Notez que le zip champ existe dans 5 des 6 documents, et est un float dans un seul document, un int dans un autre, et un string dans les trois autres.

Rockset traite les documents où le zip le champ n’existe pas comme si le champ était NULL:

> select name, zip from tudor_example1;
+--------+---------+
| name   | zip     |
|--------+---------|
| Brenda | 90210   |
| Lisa   | 91126   |
| Venkat | 94020   |
| Tudor  | 94542   |
| Hana   | <null>  |
| Igor   | 94110.0 |
+--------+---------+

> select name from tudor_example1 where zip is null;
+--------+
| name   |
|--------|
| Hana   |
+--------+

Et Rockset prend en charge une variété de cast et des fonctions d’introspection de type qui vous permettent d’interroger plusieurs types :

> select name, zip, typeof(zip) as type from tudor_example1
  where typeof(zip) <> 'string';
+--------+--------+---------+
| name   | type   | zip     |
|--------+--------+---------|
| Igor   | float  | 94110.0 |
| Tudor  | int    | 94542   |
+--------+--------+---------+

> select name, zip::string as zip_str from tudor_example1;
+--------+-----------+
| name   | zip_str   |
|--------+-----------|
| Hana   | <null>    |
| Venkat | 94020     |
| Tudor  | 94542     |
| Igor   | 94110     |
| Lisa   | 91126     |
| Brenda | 90210     |
+--------+-----------+

> select name, zip::string zip from tudor_example1
  where zip::string = '94542';
+--------+-------+
| name   | zip   |
|--------+-------|
| Tudor  | 94542 |
+--------+-------+

Interroger des données imbriquées

Rockset vous permet également d’interroger efficacement des données profondément imbriquées en traitant les tableaux imbriqués comme des tables de niveau supérieur et en vous permettant d’utiliser la syntaxe SQL complète pour les interroger.

Augmentons le même ensemble de données et ajoutons des informations sur l’endroit où ces personnes travaillent :

{"name": "Tudor", "age": 40, "zip": 94542, "jobs": [{"company":"FB", "start":2009}, {"company":"Rockset", "start":2016}] }
{"name": "Lisa", "age": 21, "zip": "91126"}
{"name": "Hana"}
{"name": "Igor", "zip": 94110.0, "jobs": [{"company":"FB", "start":2013}]}
{"name": "Venkat", "age": 35, "zip": "94020", "jobs": [{"company": "ORCL", "start": 2000}, {"company":"Rockset", "start":2016}]}
{"name": "Brenda", "age": 44, "zip": "90210"}

Ajoutez les documents à une nouvelle collection :

$ rock add tudor_example2 /tmp/example_docs
 COLLECTION       ID                                      STATUS   ERROR
tudor_example2   a176b351-9797-4ea1-9869-1ec6205b7788-1   ADDED    None
tudor_example2   a176b351-9797-4ea1-9869-1ec6205b7788-2   ADDED    None
tudor_example2   a176b351-9797-4ea1-9869-1ec6205b7788-3   ADDED    None
tudor_example2   a176b351-9797-4ea1-9869-1ec6205b7788-4   ADDED    None
tudor_example2   a176b351-9797-4ea1-9869-1ec6205b7788-5   ADDED    None

Nous soutenons le semi-standard UNNEST Fonction de table SQL pouvant être utilisée dans une jointure ou une sous-requête pour « exploser » un champ tableau :

> select p.name, j.company, j.start from
  tudor_example2 p cross join unnest(p.jobs) j 
  order by j.start, p.name;
+-----------+--------+---------+
| company   | name   | start   |
|-----------+--------+---------|
| ORCL      | Venkat | 2000    |
| FB        | Tudor  | 2009    |
| FB        | Igor   | 2013    |
| Rockset   | Tudor  | 2016    |
| Rockset   | Venkat | 2016    |
+-----------+--------+---------+

Le test d’existence peut être fait avec la semi-jointure habituelle (IN / EXISTS sous-requête). Notre optimiseur reconnaît le fait que vous interrogez un champ imbriqué sur la même collection et est capable d’exécuter la requête efficacement. Obtenons la liste des personnes qui ont travaillé chez Facebook :

> select name from tudor_example2 
  where 'FB' in (select company from unnest(jobs) j);
+--------+
| name   |
|--------|
| Tudor  |
| Igor   |
+--------+

Si vous ne vous souciez que des tableaux imbriqués (mais que vous n’avez pas besoin d’établir une corrélation avec la collection parente), nous avons une syntaxe spéciale pour cela ; tout tableau imbriqué d’objets peut être exposé en tant que table de niveau supérieur :

> select * from tudor_example2.jobs j;
+-----------+---------+
| company   | start   |
|-----------+---------|
| ORCL      | 2000    |
| Rockset   | 2016    |
| FB        | 2009    |
| Rockset   | 2016    |
| FB        | 2013    |
+-----------+---------+

J’espère que vous pourrez voir les avantages de la capacité de Rockset à ingérer des données brutes, sans aucune préparation ni modélisation de schéma, tout en alimentant efficacement le SQL fortement typé.

Dans les prochains articles, nous changerons de vitesse et plongerons dans les détails de certains défis intéressants que nous avons rencontrés lors de la construction de Rockset. Restez à l’écoute!