Flattening: Transformação de dados utilizando JSONata

Objetivo

Alguns serviços SaaS permitem a criação de campos personalizados. No caso em questão, trata-se de campos de informação criados para acompanhar chamados de atendimento - também conhecidos como tickets. Normalmente, esses campos possuem campos de título ou descrição, porém, geralmente as APIs desses serviços utilizam identificadores numéricos para referenciá-los.

Exemplo: Um ticket no Zendesk com campos personalizados contém uma propriedade chamada custom_fields contendo uma lista de ids e seus valores.

"custom_fields": [
  {
    "id": 360032901812,
    "value": "0457000"
  },
  {
    "id": 360032943991,
    "value": "Depto Suporte"
  },
  {
    "id": 360030824791,
    "value": "XDY667"
  }
]

Cada custom filed possui seu nome, mas a comunicação por API utiliza apenas o ID para referenciar os campos. Para trabalharmos com dados analíticos vamos "traduzir" os ID para os seus nomes verdadeiros utilizando a tabela abaixo, que foi construída apenas para esse propósito:

 "map": {
  "360032901812": "CEP",
  "360032943991": "Contato Assistência",
  "360030824791": "Cupom"
}

O objetivo é transformar a informação original do Zendesk em um objeto contendo os nomes das propriedades, como no exemplo abaixo:

 "resp": {
  "CEP": "0457000",
  "Contato Assistência": "Depto Suporte",
  "Cupom": "XDY667"
}

Registro de Tickets Completo

Segue abaixo a listagem do conteúdo JSON original que utilizaremos. Observe que existem dois tickets em uma lista na propriedade tickets e o mapeamento está na propriedade map.

{
 "tickets": [
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612022.json",
     "id": 612022,
     "external_id": null,
     "created_at": "2023-10-18T08:12:42Z",
     "updated_at": "2023-10-18T08:212:42Z",
     "subject": "Importação não funcional",
     "description": "Cliente reclama que a importação trava com o seu arquivo",
     "custom_fields": [
       {
         "id": 360032901812,
         "value": "0457000"
       },
       {
         "id": 360032943991,
         "value": "Depto Suporte"
       },
       {
         "id": 360030824791,
         "value": "XDY667"
       }
     ],
     "satisfaction_rating": null,
     "sharing_agreement_ids": [],
     "custom_status_id": 1900000838127
   },
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612011.json",
     "id": 612011,
     "external_id": null,
     "created_at": "2023-10-18T03:26:42Z",
     "updated_at": "2023-10-18T03:26:42Z",
     "subject": "Problema de acesso ao site. Cliente novo",
     "description": "Cliente reclama que o browser não dá acesso ao site",
     "custom_fields": [
       {
         "id": 360032901812,
         "value": "0381000"
       },
       {
         "id": 360032943991,
         "value": "Parceiro"
       },
       {
         "id": 360030824791,
         "value": "WQK890"
       }
     ],
     "satisfaction_rating": null,
     "sharing_agreement_ids": [],
     "custom_status_id": 1900000838249
   }
 ],
 "map": {
   "360032901812": "CEP",
   "360032943991": "Contato Assistência",
   "360030824791": "Cupom"
 }
}

E no final da transformação desejamos chegar ao seguinte resultado:

{
 "tickets": [
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612022.json",
     "id": 612022,
     "external_id": null,
     "created_at": "2023-10-18T08:12:42Z",
     "updated_at": "2023-10-18T08:212:42Z",
     "subject": "Importação não funcional",
     "description": "Cliente reclama que a importação trava com o seu arquivo",
     "satisfaction_rating": null,
     "custom_status_id": 1900000838127,
     "CEP": "0457000",
     "Contato Assistência": "Depto Suporte",
     "Cupom": "XDY667"
   },
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612011.json",
     "id": 612011,
     "external_id": null,
     "created_at": "2023-10-18T03:26:42Z",
     "updated_at": "2023-10-18T03:26:42Z",
     "subject": "Problema de acesso ao site. Cliente novo",
     "description": "Cliente reclama que o browser não dá acesso ao site",
     "satisfaction_rating": null,
     "custom_status_id": 1900000838249,
     "CEP": "0381000",
     "Contato Assistência": "Parceiro",
     "Cupom": "WQK890"
   }
 ]
}

Solução

Para solucionar a situação, siga os seguintes passos:

Passo 1: Criar uma função que permita encontrar um nome de custom field baseado-se apenas no seu ID.

Ou seja, ao fornecer um ID, como por exemplo 360035700992, a função busca e identifica o nome correspondente dentro de uma lista de mapeamento.

Para isso usamos a função $lookup do JSONata, como por exemplo:

Input
JSONata
Output
{
 "map": {
    "360032901812": "CEP",
    "360032943991": "Contato Assistência",
    "360030824791": "Cupom"
  }
}
$lookup(map, "360032901812")

"CEP"

Observe que a função $lookup recebe como parâmetros uma lista e uma string que contém o nome da propriedade a ser buscada na lista. A função retorna o valor da propriedade encontrada.


Passo 2: Mapear cada entrada de um custom field em uma propriedade cujo valor é o mesmo valor do custom field.

Para isso, vamos utilizar a função $map do JSONata. Essa função recebe uma lista e permite a definição de uma outra função que será executada para cada um dos elementos da lista. É equivalente a um laço FOR que executará a função para cada um dos elementos da lista.

Conseguimos executar essa operação através do código abaixo:

Input
JSONata
Output
{  
  "custom_fields": [
    {
      "id": 360032901812,
      "value": "0457000"
    },
    {
      "id": 360032943991,
      "value": "Depto Suporte"
    },
    {
      "id": 360030824791,
      "value": "XDY667"
    }
  ],
  "map": {
    "360032901812": "CEP",
    "360032943991": "Contato Assistência",
    "360030824791": "Cupom"
  }
}
$map(custom_fields, function($v, $i, $a) {

        {$lookup(map,$string($v.id)) : $v.value}

    })
[
  {
    "CEP": "0457000"
  },
  {
    "Contato Assistência": "Depto Suporte"
  },
  {
    "Cupom": "XDY667"
  }
]

Observe que a função $map recebeu a lista custom_fields como parâmetro. Além disso, especificamos uma função através da chamada function($v, $i, $a){}. As variáveis especificadas nesta chamada recebem respectivamente os seguintes valores:

  • $v : elemento do array que está sendo processado

  • $i : indice do elemento no array

  • $a : o array inteiro

Utilizamos as variáveis acima dentro da função para referenciar o que precisamos processar.

Essa função retorna um objeto para cada elemento da lista que é processado. O conteúdo desse objeto é dado por:

                 {$lookup(map,$string($v.id)) : $v.value}

Observe que o nome da propriedade na resposta é exatamente o resultado da função de $lookup quando buscamos no objeto map pela propriedade cujo nome é o valor da propriedade id do elemento sendo processado ($v.id). E o valor da resposta é exatamente o valor da propriedade value do mesmo elemento.

Na primeira interação desse $map teremos:

$v

{
  "id": 360032901812,
  "value": "0457000"
},

$i

0

$a

[
  {
    "id": 360032901812,
    "value": "0457000"
  },
  {
    "id": 360032943991,
    "value": "Depto Suporte"
  },
  {
    "id": 360030824791,
    "value": "XDY667"
  }
]

Portanto o objeto retornado é:

                            {"CEP" : "0457000"}

Passo 3: Juntar objetos de uma lista em um único objeto

Observe que a resposta do $map no passo anterior retorna uma lista onde cada objeto é uma propriedade diferente. Precisamos agora juntar esses objetos de uma lista em um único objeto. Para isso, vamos utilizar a função $merge.

No nosso exemplo, basta passar o resultado da operação anterior como parâmetro da função $merge. Essa função concatena todas as propriedades de todos objetos na lista em um único objeto.

Input
JSONata
Output
{  
  "custom_fields": [
    {
      "id": 360032901812,
      "value": "0457000"
    },
    {
      "id": 360032943991,
      "value": "Depto Suporte"
    },
    {
      "id": 360030824791,
      "value": "XDY667"
    }
  ],
  "map": {
    "360032901812": "CEP",
    "360032943991": "Contato Assistência",
    "360030824791": "Cupom"
  }
}
$merge($map(custom_fields, function($v, $i, $a) {

        {$lookup(map,$string($v.id)) : $v.value}

    }))
{
  "CEP": "0457000",
  "Contato Assistência": "Depto Suporte",
  "Cupom": "XDY667"
}

Passo 4: Função $flat

Sabemos que essa função do passo 03 deverá ser executada para cada um dos tickets e, portanto, teremos que invocá-la muitas vezes. Por essa razão, criamos uma função que acomoda esse código e espera apenas a propriedade custom_fields do ticket para ser executada.

Input
JSONata
Output
{ 
 "custom_fields": [
   {
     "id": 360032901812,
     "value": "0457000"
   },
   {
     "id": 360032943991,
     "value": "Depto Suporte"
   },
   {
     "id": 360030824791,
     "value": "XDY667"
   }
 ],
 "map": {
   "360032901812": "CEP",
   "360032943991": "Contato Assistência",
   "360030824791": "Cupom"
 }
}
   $flat := function($fld) {

   $merge($map($fld, function($v, $i, $a) {

       {$lookup(map,$string($v.id)) : $v.value}

   }))};

   /* Exemplo de chamada da função */

   {"resp" : $flat(custom_fields)};

)
{
 "resp": {
   "CEP": "0457000",
   "Contato Assistência": "Depto Suporte",
   "Cupom": "XDY667"
 }
}

Observe que criamos a função $flat definida como:

                       $flat := function($fld) { };

O código da função está definido dentro das chaves sendo exatamente igual à função definida no passo anterior. A única diferença é que parametrizamos a lista de custom_fields na forma da variável $fld.

Outra mudança observada é que precisamos envolver o código com parênteses que englobam todas as especificações funcionais. Isso nos permite definir variáveis e funções que terminam com ponto e vírgula (;).

Dentro desse bloco programático precisamos invocar a função para que o JSONata efetivamente execute o código. É exatamente isso que fazemos com a seguinte linha:

                      {"resp" : $flat(custom_fields)};

Chamamos a função passando a lista definida em custom_field na variável $fld. Com isso recebemos o objeto que desejávamos.

Finalmente nota-se que utilizamos linhas de comentários para documentar o código. Essas linhas possuem delimitadores no formato /* texto */.


Passo 5: Consolidando os campos mapeados no objeto de ticket original com a função $merge

Já geramos no passo 4 um objeto contendo os campos do custom_fields já mapeados. Precisamos agora inserir todas as propriedades desse novo objeto no objeto do ticket original. A melhor forma de fazer isso é utilizando a função $merge. Essa função recebe um array de objetos e consolida todas as propriedades dos objetos individuais em um único objeto.

Entretanto, precisamos construir um array que contenha dois objetos, um com as propriedades originais do ticket e outro com as novas propriedades. Conseguimos fazer isso com a seguinte linha de código:

               $append(ticket, $flat(ticket.custom_fields))

A função $append neste caso anexa dois objetos à uma lista, sendo que primeiro objeto é o tickets e o segundo objeto é o resultado da nossa operação $flat que também retorna um objeto. Nesse caso a função $append retorna uma lista contendo os dois objetos mencionados.

Nos falta apenas executar a função $merge sobre a lista gerada. O JSONata nos permite uma sintaxe alternativa na qual indicamos uma função que recebe como argumento o resultado do código previamente especificado. Segue o exemplo abaixo:

          $append(ticket, $flat(ticket.custom_fields)) ~> merge()

No exemplo acima, a função $merge recebe o resultado da função $append como parâmetro de execução. Segue abaixo o exemplo completo para este passo:

Input
JSONata
Output
index.js
{
 "tickets": [
   {
     "description": "Cliente reclama que a importação trava com o seu arquivo",
     "custom_fields": [
       {
         "id": 360032901812,
         "value": "0457000"
       },
       {
         "id": 360032943991,
         "value": "Depto Suporte"
       },
       {
         "id": 360030824791,
         "value": "XDY667"
       }
     ]
   }
 ],
 "map": {
   "360032901812": "CEP",
   "360032943991": "Contato Assistência",
   "360030824791": "Cupom"
 }
}
(

   /* Função que mapeia os customs_fields e devolve um objeto com as propriedades */

   $flat := function($fld)

       {$merge($map($fld, function($v, $i, $a) {

           {$lookup(map,$string($v.id)) : $v.value}

       }))};

   {

       /* código de teste */

       "register":$append(tickets[0], $flat(tickets[0].custom_fields)) ~> $merge()

   }

)
{
 "register": {
   "description": "Cliente reclama que a importação trava com o seu arquivo",
   "custom_fields": [
     {
       "id": 360032901812,
       "value": "0457000"
     },
     {
       "id": 360032943991,
       "value": "Depto Suporte"
     },
     {
       "id": 360030824791,
       "value": "XDY667"
     }
   ],
   "CEP": "0457000",
   "Contato Assistência": "Depto Suporte",
   "Cupom": "XDY667"
 }
}

Observe que a resposta contém todas as propriedades originais do ticket, mais as propriedades geradas no mapeamento. Mais adiante apresentaremos uma forma de remover o campo custom_fields que tornou-se desnecessário depois do mapeamento.


Passo 6: Processamento de múltiplos tickets usando $map e a função $all Até o passo 5 consideramos a manipulação de um único ticket, porém recebemos múltiplos tickets em um único array e precisamos processar cada um dos tickets individualmente. Esse é exatamente o propósito da função $map que será utilizada mais uma vez para processar cada um dos tickets individualmente.

Faremos isso criando uma nova função chamada $all cujo objetivo é processar todos os tickets, como definido abaixo:

            $all := $map(tickets, function($r, $s, $t) {

            $append($r, $flat($r.custom_fields)) ~> $merge()

             }) ;

Basicamente essa função executa o passo 5 para cada um dos tickets recebidos no array tickets.

Existe apenas um problema na abordagem acima que se manifesta apenas quando recebemos um array vazio de tickets. Na forma como está acima, o resultado seria apenas um objeto vazio. Para garantir que essa execução sempre retorne um array, mesmo que vazio, executamos um $append vazio na sequência da função. Esse $append é inócuo quando a operação resultar um array de tickets, mas será útil quando a função retornar vazio, pois transformará o objeto vazio em um array vazio.

             $all := $map(tickets, function($r, $s, $t) {
             $append($r, $flat($r.custom_fields)) ~> $merge()

             }) ~> $append([]);

Com isso, nosso código fica:

Input
JSONata
Output
{
 "tickets": [
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612022.json",
     "id": 612022,
     "external_id": null,
     "created_at": "2023-10-18T08:12:42Z",
     "updated_at": "2023-10-18T08:212:42Z",
     "subject": "Importação não funcional",
     "description": "Cliente reclama que a importação trava com o seu arquivo",
     "custom_fields": [
       {
         "id": 360032901812,
         "value": "0457000"
       },
       {
         "id": 360032943991,
         "value": "Depto Suporte"
       },
       {
         "id": 360030824791,
         "value": "XDY667"
       }
     ],
     "satisfaction_rating": null,
     "sharing_agreement_ids": [],
     "custom_status_id": 1900000838127
   },
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612011.json",
     "id": 612011,
     "external_id": null,
     "created_at": "2023-10-18T03:26:42Z",
     "updated_at": "2023-10-18T03:26:42Z",
     "subject": "Problema de acesso ao site. Cliente novo",
     "description": "Cliente reclama que o browser não dá acesso ao site",
     "custom_fields": [
       {
         "id": 360032901812,
         "value": "0381000"
       },
       {
         "id": 360032943991,
         "value": "Parceiro"
       },
       {
         "id": 360030824791,
         "value": "WQK890"
       }
     ],
     "satisfaction_rating": null,
     "sharing_agreement_ids": [],
     "custom_status_id": 1900000838249
   }
 ],
 "map": {
   "360032901812": "CEP",
   "360032943991": "Contato Assistência",
   "360030824791": "Cupom"
 }
}
(

   /* Função que mapeia os customs_fields e devolve um objeto com as propriedades */

   $flat := function($fld)

       {$merge($map($fld, function($v, $i, $a) {

           {$lookup(map,$string($v.id)) : $v.value}

       }))};

   /* Função que executa o mapeamento para cada um dos tickets */ 

   $all := $map(tickets, function($r, $s, $t) {

       $append($r, $flat($r.custom_fields)) ~> $merge()

   }) ~> $append([]);

   {

       /* executa função */

       "registers":$all

   }

)
{
 "registers": [
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612022.json",
     "id": 612022,
     "external_id": null,
     "created_at": "2023-10-18T08:12:42Z",
     "updated_at": "2023-10-18T08:212:42Z",
     "subject": "Importação não funcional",
     "description": "Cliente reclama que a importação trava com o seu arquivo",
     "custom_fields": [
       {
         "id": 360032901812,
         "value": "0457000"
       },
       {
         "id": 360032943991,
         "value": "Depto Suporte"
       },
       {
         "id": 360030824791,
         "value": "XDY667"
       }
     ],
     "satisfaction_rating": null,
     "sharing_agreement_ids": [],
     "custom_status_id": 1900000838127,
     "CEP": "0457000",
     "Contato Assistência": "Depto Suporte",
     "Cupom": "XDY667"
   },
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612011.json",
     "id": 612011,
     "external_id": null,
     "created_at": "2023-10-18T03:26:42Z",
     "updated_at": "2023-10-18T03:26:42Z",
     "subject": "Problema de acesso ao site. Cliente novo",
     "description": "Cliente reclama que o browser não dá acesso ao site",
     "custom_fields": [
       {
         "id": 360032901812,
         "value": "0381000"
       },
       {
         "id": 360032943991,
         "value": "Parceiro"
       },
       {
         "id": 360030824791,
         "value": "WQK890"
       }
     ],
     "satisfaction_rating": null,
     "sharing_agreement_ids": [],
     "custom_status_id": 1900000838249,
     "CEP": "0381000",
     "Contato Assistência": "Parceiro",
     "Cupom": "WQK890"
   }
 ]
}

Passo 7: Removendo propriedades indesejadas com o Transform

Observe que o resultado no passo 06 ainda exige algumas propriedades que não são mais desejadas, como o custom_fields. Vamos remover essa propriedade assim como o sharing_agreements_ids para ilustrar como remover propriedades indesejadas.

Utilizamos o operador Transform para executar esse tipo de remoção. A operação abaixo remove as propriedades:

        $all ~> |$ |$, ["custom_fields", "sharing_agreement_ids"]|

Observe que invocamos a função $all e depois submetemos o resultado dela ao operador Transform que recebe três argumentos. Os dois primeiros $ referem-se ao objeto raiz e a lista seguinte possui as propriedades que são removidas.

Com isso, chegamos ao nosso resultado final:

Input
JSONata
Output
{
 "tickets": [
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612022.json",
     "id": 612022,
     "external_id": null,
     "created_at": "2023-10-18T08:12:42Z",
     "updated_at": "2023-10-18T08:212:42Z",
     "subject": "Importação não funcional",
     "description": "Cliente reclama que a importação trava com o seu arquivo",
     "custom_fields": [
       {
         "id": 360032901812,
         "value": "0457000"
       },
       {
         "id": 360032943991,
         "value": "Depto Suporte"
       },
       {
         "id": 360030824791,
         "value": "XDY667"
       }
     ],
     "satisfaction_rating": null,
     "sharing_agreement_ids": [],
     "custom_status_id": 1900000838127
   },
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612011.json",
     "id": 612011,
     "external_id": null,
     "created_at": "2023-10-18T03:26:42Z",
     "updated_at": "2023-10-18T03:26:42Z",
     "subject": "Problema de acesso ao site. Cliente novo",
     "description": "Cliente reclama que o browser não dá acesso ao site",
     "custom_fields": [
       {
         "id": 360032901812,
         "value": "0381000"
       },
       {
         "id": 360032943991,
         "value": "Parceiro"
       },
       {
         "id": 360030824791,
         "value": "WQK890"
       }
     ],
     "satisfaction_rating": null,
     "sharing_agreement_ids": [],
     "custom_status_id": 1900000838249
   }
 ],
 "map": {
   "360032901812": "CEP",
   "360032943991": "Contato Assistência",
   "360030824791": "Cupom"
 }
}
(

   /* Função que mapeia os customs_fields e devolve um objeto com as propriedades */

   $flat := function($fld)

       {$merge($map($fld, function($v, $i, $a) {

           {$lookup(map,$string($v.id)) : $v.value}

       }))};

   /* Função que executa o mapeamento para cada um dos tickets */

   $all := $map(tickets, function($r, $s, $t) {

       $append($r, $flat($r.custom_fields)) ~> $merge()

   }) ~> $append([]);

   {

       /* executa e remove propriedades indesejadas */

       "tickets":$all ~> |$ |$, ["custom_fields", "sharing_agreement_ids"]|

   }

)
{
 "tickets": [
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612022.json",
     "id": 612022,
     "external_id": null,
     "created_at": "2023-10-18T08:12:42Z",
     "updated_at": "2023-10-18T08:212:42Z",
     "subject": "Importação não funcional",
     "description": "Cliente reclama que a importação trava com o seu arquivo",
     "satisfaction_rating": null,
     "custom_status_id": 1900000838127,
     "CEP": "0457000",
     "Contato Assistência": "Depto Suporte",
     "Cupom": "XDY667"
   },
   {
     "url": "https://soft.zendesk.com/api/v2/tickets/612011.json",
     "id": 612011,
     "external_id": null,
     "created_at": "2023-10-18T03:26:42Z",
     "updated_at": "2023-10-18T03:26:42Z",
     "subject": "Problema de acesso ao site. Cliente novo",
     "description": "Cliente reclama que o browser não dá acesso ao site",
     "satisfaction_rating": null,
     "custom_status_id": 1900000838249,
     "CEP": "0381000",
     "Contato Assistência": "Parceiro",
     "Cupom": "WQK890"
   }
 ]
}