Como converter JSON simples arbitrário em CSV usando jq?

105

Usando jq , como a codificação JSON arbitrária de uma matriz de objetos superficiais pode ser convertida em CSV?

Há uma abundância de perguntas e respostas neste site que cobrem modelos de dados específicos que codificam os campos, mas as respostas a esta pergunta devem funcionar em qualquer JSON, com a única restrição de que é uma matriz de objetos com propriedades escalares (sem profundidade / complexa / subobjetos, já que achatá-los é outra questão). O resultado deve conter uma linha de cabeçalho com os nomes dos campos. Será dada preferência a respostas que preservem a ordem dos campos do primeiro objeto, mas não é um requisito. Os resultados podem incluir todas as células com aspas duplas ou incluir apenas aquelas que requerem aspas (por exemplo, 'a, b').

Exemplos

  1. Entrada:

    [
        {"code": "NSW", "name": "New South Wales", "level":"state", "country": "AU"},
        {"code": "AB", "name": "Alberta", "level":"province", "country": "CA"},
        {"code": "ABD", "name": "Aberdeenshire", "level":"council area", "country": "GB"},
        {"code": "AK", "name": "Alaska", "level":"state", "country": "US"}
    ]
    

    Resultado possível:

    code,name,level,country
    NSW,New South Wales,state,AU
    AB,Alberta,province,CA
    ABD,Aberdeenshire,council area,GB
    AK,Alaska,state,US
    

    Resultado possível:

    "code","name","level","country"
    "NSW","New South Wales","state","AU"
    "AB","Alberta","province","CA"
    "ABD","Aberdeenshire","council area","GB"
    "AK","Alaska","state","US"
    
  2. Entrada:

    [
        {"name": "bang", "value": "!", "level": 0},
        {"name": "letters", "value": "a,b,c", "level": 0},
        {"name": "letters", "value": "x,y,z", "level": 1},
        {"name": "bang", "value": "\"!\"", "level": 1}
    ]
    

    Resultado possível:

    name,value,level
    bang,!,0
    letters,"a,b,c",0
    letters,"x,y,z",1
    bang,"""!""",0
    

    Resultado possível:

    "name","value","level"
    "bang","!","0"
    "letters","a,b,c","0"
    "letters","x,y,z","1"
    "bang","""!""","1"
    
outis
fonte
Mais de três anos depois ... um genérico json2csvestá em stackoverflow.com/questions/57242240/…
pico de

Respostas:

159

Primeiro, obtenha uma matriz contendo todos os nomes de propriedades de objeto diferentes em sua entrada de matriz de objeto. Essas serão as colunas do seu CSV:

(map(keys) | add | unique) as $cols

Em seguida, para cada objeto na entrada da matriz do objeto, mapeie os nomes das colunas obtidos para as propriedades correspondentes no objeto. Essas serão as linhas do seu CSV.

map(. as $row | $cols | map($row[.])) as $rows

Finalmente, coloque os nomes das colunas antes das linhas, como um cabeçalho para o CSV, e passe o fluxo de linhas resultante para o @csvfiltro.

$cols, $rows[] | @csv

Todos juntos agora. Lembre-se de usar o -rsinalizador para obter o resultado como uma string bruta:

jq -r '(map(keys) | add | unique) as $cols | map(. as $row | $cols | map($row[.])) as $rows | $cols, $rows[] | @csv'

fonte
6
É bom que sua solução capture todos os nomes de propriedades de todas as linhas, em vez de apenas a primeira. Eu me pergunto quais são as implicações de desempenho disso para documentos muito grandes. PS: Se você quiser, pode se livrar da $rowsatribuição de variável apenas inserindo-a:(map(keys) | add | unique) as $cols | $cols, map(. as $row | $cols | map($row[.]))[] | @csv
Jordan em execução em
9
Obrigado, Jordan! Estou ciente de que $rowsnão precisa ser atribuído a uma variável; Só pensei que atribuí-lo a uma variável tornava a explicação mais agradável.
3
considere converter o valor da linha | string no caso de haver matrizes ou mapas aninhados.
TJR de
Boa sugestão, @TJR. Talvez se houver estruturas aninhadas, jq deva recorrer a elas e transformar seus valores em colunas também
LS
Como isso seria diferente se o JSON estivesse em um arquivo e você quisesse filtrar alguns dados específicos para CSV?
Neo
91

The Skinny

jq -r '(.[0] | keys_unsorted) as $keys | $keys, map([.[ $keys[] ]])[] | @csv'

ou:

jq -r '(.[0] | keys_unsorted) as $keys | ([$keys] + map([.[ $keys[] ]])) [] | @csv'

Os detalhes

a parte, de lado

Descrever os detalhes é complicado porque jq é orientado a fluxo, o que significa que opera em uma sequência de dados JSON, em vez de um único valor. O fluxo JSON de entrada é convertido em algum tipo interno que é passado pelos filtros e, em seguida, codificado em um fluxo de saída no final do programa. O tipo interno não é modelado por JSON e não existe como um tipo nomeado. É mais facilmente demonstrado examinando a saída de um índice simples ( .[]) ou do operador vírgula (examiná-lo diretamente poderia ser feito com um depurador, mas isso seria em termos de tipos de dados internos de jq, em vez dos tipos de dados conceituais por trás de JSON) .

$ jq -c '. []' <<< '["a", "b"]'
"uma"
"b"
$ jq -cn '"a", "b"'
"uma"
"b"

Observe que a saída não é uma matriz (o que seria ["a", "b"]). A saída compacta (a -copção) mostra que cada elemento da matriz (ou argumento do ,filtro) se torna um objeto separado na saída (cada um está em uma linha separada).

Um stream é como um JSON-seq , mas usa novas linhas em vez de RS como separador de saída quando codificado. Consequentemente, este tipo interno é referido pelo termo genérico "sequência" nesta resposta, com "fluxo" sendo reservado para a entrada e saída codificadas.

Construindo o Filtro

As chaves do primeiro objeto podem ser extraídas com:

.[0] | keys_unsorted

Geralmente, as chaves são mantidas em sua ordem original, mas a preservação da ordem exata não é garantida. Conseqüentemente, eles precisarão ser usados ​​para indexar os objetos para obter os valores na mesma ordem. Isso também evitará que os valores estejam nas colunas erradas se alguns objetos tiverem uma ordem de chave diferente.

Para gerar as chaves como a primeira linha e torná-las disponíveis para indexação, elas são armazenadas em uma variável. O próximo estágio do pipeline faz referência a essa variável e usa o operador vírgula para anexar o cabeçalho ao fluxo de saída.

(.[0] | keys_unsorted) as $keys | $keys, ...

A expressão após a vírgula é um pouco complicada. O operador de índice em um objeto pode receber uma sequência de strings (por exemplo "name", "value"), retornando uma sequência de valores de propriedade para essas strings. $keysé uma matriz, não uma sequência, então []é aplicada para convertê-la em uma sequência,

$keys[]

que pode então ser passado para .[]

.[ $keys[] ]

Isso também produz uma sequência, portanto, o construtor de matriz é usado para convertê-la em uma matriz.

[.[ $keys[] ]]

Esta expressão deve ser aplicada a um único objeto. map()é usado para aplicá-lo a todos os objetos na matriz externa:

map([.[ $keys[] ]])

Por último, para este estágio, isso é convertido em uma sequência para que cada item se torne uma linha separada na saída.

map([.[ $keys[] ]])[]

Por que agrupar a sequência em um array dentro do mapapenas para descompactá-la fora? mapproduz uma matriz; .[ $keys[] ]produz uma sequência. Aplicar mapà sequência de .[ $keys[] ]produziria uma matriz de sequências de valores, mas como as sequências não são do tipo JSON, em vez disso, você obtém uma matriz achatada contendo todos os valores.

["NSW","AU","state","New South Wales","AB","CA","province","Alberta","ABD","GB","council area","Aberdeenshire","AK","US","state","Alaska"]

Os valores de cada objeto precisam ser mantidos separados, para que se tornem linhas separadas na saída final.

Por fim, a sequência é passada pelo @csvformatador.

Alternar

Os itens podem ser separados mais tarde, em vez de mais cedo. Em vez de usar o operador vírgula para obter uma sequência (passando uma sequência como o operando correto), a sequência de cabeçalho ( $keys) pode ser agrupada em uma matriz e +usada para anexar a matriz de valores. Isso ainda precisa ser convertido em uma sequência antes de ser passado para @csv.

outis
fonte
3
Você pode usar em keys_unsortedvez de keyspara preservar a ordem das chaves do primeiro objeto?
Jordan em
2
@outis - O preâmbulo sobre streams é um tanto impreciso. O simples fato é que os filtros jq são orientados por fluxo. Ou seja, qualquer filtro pode aceitar um fluxo de entidades JSON e alguns filtros podem produzir um fluxo de valores. Não há "nova linha" ou qualquer outro separador entre os itens em um fluxo - é apenas quando eles são impressos que um separador é introduzido. Para ver por si mesmo, tente: jq -n -c 'reduzir ("a", "b") as $ s ("";. + $ S)'
pico de
2
@peak - aceite esta como a resposta, é de longe a mais completa e abrangente
btk
@btk - Não fiz a pergunta e, portanto, não posso aceitá-la.
pico de
1
@Wyatt: observe mais de perto seus dados e a entrada de exemplo. A questão é sobre uma série de objetos, não um único objeto. Experimente [{"a":1,"b":2,"c":3}].
outis
6

Criei uma função que produz uma matriz de objetos ou matrizes para csv com cabeçalhos. As colunas estariam na ordem dos cabeçalhos.

def to_csv($headers):
    def _object_to_csv:
        ($headers | @csv),
        (.[] | [.[$headers[]]] | @csv);
    def _array_to_csv:
        ($headers | @csv),
        (.[][:$headers|length] | @csv);
    if .[0]|type == "object"
        then _object_to_csv
        else _array_to_csv
    end;

Então você pode usá-lo assim:

to_csv([ "code", "name", "level", "country" ])
Jeff Mercado
fonte
6

O filtro a seguir é ligeiramente diferente, pois garante que cada valor seja convertido em uma string. (Nota: use jq 1.5+)

# For an array of many objects
jq -f filter.jq (file)

# For many objects (not within array)
jq -s -f filter.jq (file)

Filtro: filter.jq

def tocsv($x):
    $x
    |(map(keys)
        |add
        |unique
        |sort
    ) as $cols
    |map(. as $row
        |$cols
        |map($row[.]|tostring)
    ) as $rows
    |$cols,$rows[]
    | @csv;

tocsv(.)
TJR
fonte
1
Isso funciona bem para JSON simples, mas e o JSON com propriedades aninhadas que descem muitos níveis?
Amir
Isso, claro, classifica as chaves. Além disso, a saída de uniqueé classificada de qualquer maneira, portanto, unique|sortpode ser simplificada para unique.
pico de
1
@TJR Ao usar este filtro, é obrigatório ativar a saída bruta usando a -ropção. Caso contrário, todas as aspas "terão escape extra, o que não é um CSV válido.
tosh
Amir: propriedades aninhadas não mapeiam para CSV.
chrishmorris
2

Esta variante do programa de Santiago também é segura, mas garante que os nomes das chaves no primeiro objeto sejam usados ​​como os cabeçalhos das primeiras colunas, na mesma ordem em que aparecem nesse objeto:

def tocsv:
  if length == 0 then empty
  else
    (.[0] | keys_unsorted) as $keys
    | (map(keys) | add | unique) as $allkeys
    | ($keys + ($allkeys - $keys)) as $cols
    | ($cols, (.[] as $row | $cols | map($row[.])))
    | @csv
  end ;

tocsv
pico
fonte