Deuxième article : Array ⇒ Denormalized #1

Dans le précédent article, nous avons vu une première approche pour convertir un JSON en CSV avec Spark. Néanmoins, nous nous étions arrêtés aux structures imbriquées, mais en évitant le cas des array. Cette fois, nous allons nous attaquer à ce type de donnée.

Dans cet article, nous verrons progressivement une approche pour retirer les array des structures de données. Puis nous découvrirons un algorithme plus générique, fonctionnant dans les cas où sont présents plusieurs niveaux d'imbrication entre struct et array.

Partons d'un cas simple

Des structures de données contenant des array sont assez courantes. Par exemple :

{
  "firstname": "John",
  "lastname": "Doe",
  "contact": [
    { "type": "email", "value": "jdoe@mycompany.com" },
    { "type": "twitter", "value": "jdoe87" }
  ]
}

Vu de Spark, le document ci-dessus possède la schéma suivant (en se basant sur une description simplifiée en EDN) :

{:firstname string
 :lastname  string
 :contact   [{:type  string
              :value string}]}

Notre objectif consistera à retirer la partie array de contact pour remonter le struct sous-jacent. Pour cela nous allons utiliser la fonction explode de Spark. Cette fonction permet de partir d'une ligne contenant un array et de la découper en autant de ligne qu'il y a d'élément dans le tableau.

En appliquant explode au champ contact, nous obtenons le schéma suivant :

{:firstname string
 :lastname  string
 :contact   {:type  string
             :value string}}

Et en reconstruisant la structure à la façon de notre précédent article, nous avons le CSV suivant :

firstname, lastname, contact_type, contact_value
John,      Doe,      email,        jdoe@mycompany.com
John,      Doe,      twitter       jdoe87

Imbrication array et structure

Partons maintenant du document suivant et cherchons à le désimbriquer :

{
  "firstname": "John",
  "lastname": "Doe",
  "contact": [
    { "type": "email", "value": [{ "id": "jdoe@mycompany.com" }] },
    { "type": "internal", "value": [
        { "id": "jdoe-dev" },
        { "dept": "ops", "id": "jdoe-ops" }
      ] }
  ]
}

Le document ci-dessus possède du point de vue de Spark le schéma suivant :

{:firstname string
 :lastname  string
 :contact   [{:type  string
              :value [{:dept string
                       :id   string}]}]}

Il y a ici deux difficultés : 1/ nous sommes en présence d'un schéma imbriquant structure et tableau à plusieurs niveaux, 2/ le champ value n'a pas exactement la même structure d'une ligne à l'autre dans contact. Pour le second point, le champ dept n'est en effet présent que dans la dernière ligne.

Pour le premier point, il s'agira de détecter récursivement la présence de array et d'y appliquer le même principe vu précédemment (retirer le array et remonter le struct sous-jacent) en se basant sur explode. Pour le second point, Spark aligne automatiquement le schéma pour l'ensemble du document en recherchant un dénominateur commun. C'est ce que nous voyons dans le schéma précédent, où tous les champs value possède le champ dept (par défaut ce champ vaut null).

Voici le schéma de ce document vu par Spark :

{:firstname string
 :lastname  string
 :contact   {:type  string
             :value {:dept string
                     :id   string}}}

Du coup, un df.show(truncate = false) donne :

+---------+--------+-------------------------------+
|firstname|lastname|contact                        |
+---------+--------+-------------------------------+
|John     |Doe     |[email, [, jdoe@mycompany.com]]|
|John     |Doe     |[internal, [, jdoe-dev]]       |
|John     |Doe     |[internal, [ops, jdoe-ops]]    |
+---------+--------+-------------------------------+

Et en convertissant en JSON :

{"firstname":"John","lastname":"Doe","contact":{"type":"email","value":{"id":"jdoe@mycompany.com"}}}
{"firstname":"John","lastname":"Doe","contact":{"type":"internal","value":{"id":"jdoe-dev"}}}
{"firstname":"John","lastname":"Doe","contact":{"type":"internal","value":{"dept":"ops","id":"jdoe-ops"}}}

C'est en partant de cette idée que nous allons généraliser notre approche afin de faire disparaître les array dans un schéma.

Généralisation et algorithme

Pour présenter notre algorithme plus détailler, rentrons cette fois dans un cadre plus complexe avec plusieurs array définit dans le schéma au même niveau. Partons pour cela du schéma suivant :

{:a {:b {:c string
         :d [string]}}
 :e [{:f string
      :g [{:h string}]}]}

En première, nous allons renommer les champs avec des "symboles" différents et constituer une table de symboles. Cette permet d'éviter des cas de collision dans le cas où un même nom de champ se retrouverait dans deux sous-structures différentes. Utiliser des symboles différents permet de décider au dernier moment de la stratégie de gestion des collisions lors de la reconstitution des noms de champ.

{:a :field1
 :b :field2
 :c :field3
 :d :array4
 :e :array5
 :f :field6
 :g :array7
 :h :field8}

En se basant sur l'algorithme vu dans l'article précédent, nous aplatir une première fois les structures. Les arrays ne sont pas modifiés.

select a.b.c as field1,
       a.b.d as array4,
       e     as array5
from $input
{:field3 string
 :array4 [string]
 :array5 [{:f string
           :g [{:h string}]}]}

Nous utilisons ensuite explode_out pour faire disparaître le premier array de la structure. À la différence d'explode, explode_out permet de covserver les lignes où les array sont vides. La fonction met alors null comme valeur.

select                           field3,
        explode_outer(array4) as array4,
                                 array5
from $input
{:field3 string
 :array4 string
 :array5 [{:f string
           :g [{:h string}]}]}

Nous recommençons cette dernière opération jusqu'à ce qu'il n'y ait plus de array au premier niveau de la structure.

select                           field3,
                                 array4,
        explode_outer(array5) as array5
from $input
{:field3 string
 :array4 string
 :array5 {:f string
          :g [{:h string}]}}

Puis, sachant qu'il y a encore une sous-structure, nous recommençons un aplatissement.

select              field3,
                    array4,
        array5.f as field6,
        array5.g as array7
from $input
{:field3 string
 :array4 string
 :field6 string
 :array7 [{:h string}]} 

Et la suppression des array du niveau supérieur.

select              field3,
                    array4,
                    field6,
        exlode_outer(array7) as array7
from $input
{:field3 string
 :array4 string
 :field6 string
 :array7 {:h string}} 

Et ainsi de suite...

select              field3,
                    array4,
                    field6,
        array7.h as field8
from $input
{:field3 string,
 :array4 string,
 :field6 string,
 :field8 string}

Dès lors qu'il n'y a ni sous-structure ni array, nous arrêtons l'itération.

En utilisant la table de symboles, nous reconstruisons alors la structure.

select struct(struct(field3 as c, array4 as d) as b) as a,
       struct(struct(field6 as f, struct(field8 as h) as g) as e
from $input
{:a {:b {:c string
         :d string}}
 :e {:f string
     :g {:h string}}}

Conclusion

Nous venons de voir de manière détaillée comment l'algorithme derrière notre implémentation FlattenedNested fonctionne.

Dans l'article précédent, nous avons vu comment aplatir un dataframe en forme de JSON (Jsonoid) vers un dataframe en forme de table, mais sans prendre en compte les tableaux. Nous pouvons combiner ces deux techniques pour transférer l'intégralité d'un dataframe vers un système plus traditionnel (SGBD).

Dans les articles suivants, nous allons voir des versions configurables de ces algorithmes, afin d'aligner les structures de donnée entre différents systèmes.