Vai al contenuto
jsonbeautifiers
Italiano

JSONPath, la versione che è davvero specificata

JSONPath è stato un post di blog per diciassette anni. La RFC 9535 finalmente dice che cosa significa.

Ogni affermazione di questa pagina è misurata oppure ha una fonte. Quando non è né l’una né l’altra, la pagina lo dice.

JSONPath nacque nel 2007 come post di blog di Stefan Goessner, che abbozzava un linguaggio di interrogazione in stile XPath per JSON in circa due schermate di prosa e un’implementazione di riferimento in JavaScript. Era abbastanza buono perché tutti lo implementassero, e abbastanza vago perché tutti lo implementassero in modo diverso. L’operatore di discendenza .. considera il nodo radice stesso o solo i suoi figli? [-1] è l’ultimo elemento o un errore? Una parentesi quadra può contenere più selettori? Cosa fa un filtro quando la chiave che verifica non esiste? Cosa seleziona una fetta con passo zero? Ognuna di queste domande aveva almeno due risposte in circolazione, e il documento originale non ne risolveva nessuna, perché parti di esso erano delegate a qualunque eval() fosse a portata di mano.

La RFC 9535 ha sistemato la cosa nel febbraio 2024. È un Proposed Standard IETF, il primo livello di maturità del percorso di standardizzazione: è una specifica reale, stabile e scritta in termini normativi, e non è un Internet Standard. Trattala come tratteresti qualunque Proposed Standard, cioè come il riferimento su cui scrivere codice nuovo sapendo che molto codice in produzione lo precede.

Il documento

Tutto quel che segue gira su questo:

{
  "store": {
    "name": "Corner Books",
    "book": [
      { "category": "reference", "author": "Nigel Rees",
        "title": "Sayings of the Century", "price": 8.95 },
      { "category": "fiction", "author": "Evelyn Waugh",
        "title": "Sword of Honour", "price": 12.99 },
      { "category": "fiction", "author": "Herman Melville",
        "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 },
      { "category": "fiction", "author": "J.R.R. Tolkien",
        "title": "The Lord of the Rings", "isbn": "0-395-19395-8" }
    ]
  }
}

Nota l’ultimo libro: niente price. È in quell’assenza che vive quasi tutto il comportamento interessante.

Segmenti e selettori

Una query è $ seguito da una sequenza di segmenti. $ è la radice. Ogni segmento applica uno o più selettori a ogni nodo che si ha in mano e produce un nuovo elenco di nodi. Il risultato di una query è sempre un elenco di nodi, anche quando ne contiene uno o nessuno, ed è per questo che una libreria JSONPath restituisce un array dove una libreria JSON Pointer restituisce un valore.

$.store.book[0].title        notazione a punti, scorciatoia per i selettori di nome
$['store']['book'][0]        notazione a parentesi, significato identico
$["store"]["book"][0]        vanno bene anche le virgolette doppie

La notazione a parentesi non è un ornamento facoltativo. $.first-name non è un selettore di nome valido, quindi una chiave con un trattino, uno spazio, un punto o una cifra iniziale va scritta $['first-name']. La forma a parentesi con apici singoli è anche la forma di un percorso normalizzato, l’identificatore univoco che la RFC 9535 definisce per un singolo nodo: $['store']['book'][0]['title'].

I selettori:

  • Nome: 'title' o "title", che seleziona un membro di oggetto. Nulla su un array.
  • Jolly *: ogni valore membro di un oggetto, ogni elemento di un array. $.store.book[*] e $.store.book.* sono la stessa query.
  • Indice: un intero, base zero. I negativi contano dalla fine, quindi [-1] è l’ultimo elemento. Ora è specificato, non è una cortesia della singola libreria.
  • Fetta inizio:fine:passo: semiaperta, fine esclusa, e un passo negativo cammina all’indietro. Un passo 0 non seleziona nulla anziché sollevare un errore, l’unico punto in cui la RFC si separa deliberatamente da Python.
  • Filtro ?espr: trattato più sotto.

Un segmento figlio può contenere più selettori separati da virgole, e non devono essere dello stesso tipo. $.store.book[0, -1] sono il primo e l’ultimo libro; $.store.book[0, 2:4] mescola un indice e una fetta. I risultati tornano nell’ordine dei selettori, quindi un’unione può legittimamente restituire lo stesso nodo due volte.

Un segmento di discendenza si scrive con due punti: $..author, $..['author'], $..*, $..[0]. Visita il nodo di ingresso e ogni suo discendente, poi applica i propri selettori a ciascuno. La vecchia ambiguità è finita: $..store sul documento qui sopra corrisponde eccome a $.store, perché il segmento di discendenza parte dalla radice stessa.

I filtri, dove stanno le domande

Dentro un filtro, @ è il nodo corrente in esame e $ resta la radice dell’intero documento, quindi un filtro può confrontare un valore con qualcosa che sta altrove nel documento.

Una query nuda usata come espressione di filtro è un test di esistenza: è vera quando la query seleziona almeno un nodo. $.store.book[?@.isbn] seleziona i due libri che hanno un ISBN. Gli operatori di confronto sono ==, !=, <, <=, >, >=; quelli logici sono &&, || e il prefisso !, con le parentesi per raggruppare. Le parentesi attorno all’intera espressione sono ammesse ma non più richieste, quindi sia [?(@.price < 10)] sia [?@.price < 10] sono validi e significano lo stesso.

Gli operandi di confronto sono ristretti. Ogni lato deve essere un letterale, una query singolare (fatta solo di selettori di nome e indice, così da poter selezionare al più un nodo) o una chiamata di funzione. @.price va bene. @..price e @.book[*].price no, e un’implementazione dovrebbe rifiutare la query anziché indovinare.

Ora la regola che fa inciampare. Una query che non seleziona nulla produce il valore speciale Nothing, e Nothing non è null, non è zero e non è false. È uguale a Nothing e a nient’altro, e ogni confronto d’ordine che lo coinvolga è falso. Conseguenze:

$.store.book[?@.price < 10]     esclude il libro di Tolkien (nessun price)
$.store.book[?@.price >= 10]    lo esclude anch’esso
$.store.book[?@.price == null]  lo esclude anch’esso: Nothing non è null
$.store.book[?!@.price]         seleziona lui, e solo lui

L’assenza si verifica dunque negando il test di esistenza, e == null verifica un membro presente che contiene null. L’immagine speculare è una sorpresa vera: $.store.book[?@.price == @.discount] seleziona il libro di Tolkien, perché entrambi i lati sono Nothing e Nothing è uguale a Nothing.

Un confronto tra due tipi diversi non è mai un errore. L’uguaglianza fra tipi è semplicemente falsa, e l’ordine è definito solo fra due numeri o due stringhe, quindi @.price > "10" è falso per ogni libro.

Estensioni di funzione

Ne sono definite cinque, e sono tipizzate, quindi length(@.book[*]) è un errore di tipo anziché una sorpresa a runtime.

Funzione Prende
length() un valore valori scalari Unicode in una stringa, elementi in un array, membri in un oggetto, altrimenti Nothing
count() un elenco di nodi quanti nodi ha selezionato
match() una stringa e una regex vero se la regex corrisponde all’intera stringa
search() una stringa e una regex vero se la regex corrisponde in un punto qualsiasi della stringa
value() un elenco di nodi il valore, se l’elenco contiene esattamente un nodo, altrimenti Nothing

count() esiste perché una query non singolare non può essere operando di confronto, quindi count(@.book[?@.isbn]) == 2 è il modo di dire «ha esattamente due libri con ISBN». value() risolve lo stesso problema dall’altro lato: $[?value(@..name) == 'Corner Books'] funziona perché value() riduce una query multi-nodo a un singolo valore confrontabile, o a Nothing se ha trovato più o meno di un nodo.

Il dialetto di espressioni regolari è I-Regexp (RFC 9485), un sottoinsieme deliberatamente piccolo che si proietta sulle espressioni regolari XSD. Non è PCRE. Non aspettarti lookahead né retroriferimenti, e ricorda che match(@.category, 'fic') è falso per "fiction" mentre search(@.category, 'fic') è vero.

Che cosa non c’è nel linguaggio

Tre cose a cui si ricorre ancora non esistono. Le espressioni di script, la forma [(...)] del post originale, sono sparite, e con esse la dipendenza da eval(). Non c’è alcun operatore di risalita: una query scende soltanto, quindi se ti serve l’oggetto contenitore selezioni l’oggetto e filtri sul figlio. E la pseudo-proprietà length è sparita, quindi $.store.book[(@.length-1)] non è una query. Scrivi $.store.book[-1].

Esempi svolti

Espressione Risultato
$.store.name "Corner Books"
$.store.book[*].author tutti e quattro gli autori
$..isbn le due stringhe ISBN
$.store.book[-1].title "The Lord of the Rings"
$.store.book[1:3].title "Sword of Honour", "Moby Dick"
$.store.book[::2].title "Sayings of the Century", "Moby Dick"
$.store.book[0,-1].title "Sayings of the Century", "The Lord of the Rings"
$.store.book[?@.price < 10].title "Sayings of the Century", "Moby Dick"
$.store.book[?!@.price].title "The Lord of the Rings"
$.store.book[?@.price > $.store.book[0].price].title "Sword of Honour", "Moby Dick"
$.store.book[?match(@.category, 'fic.*')].author Waugh, Melville, Tolkien
$.store.book[?search(@.author, 'Mel')].title "Moby Dick"
$.store.book[?length(@.title) > 16].title "Sayings of the Century", "The Lord of the Rings"

Incolla il documento e una qualsiasi di queste nel tester JSONPath per vedere l’elenco di nodi accanto al percorso normalizzato di ogni corrispondenza: è il modo più rapido di controllare se la tua libreria concorda con la RFC su indici negativi e chiavi assenti.

Quando usare qualcos’altro

JSONPath seleziona nodi. È tutto il suo lavoro, e altri tre strumenti di interrogazione lo sovrappongono:

JSON Pointer (RFC 6901) indirizza esattamente un nodo, senza jolly, senza filtri e senza ambiguità: /store/book/0/title, con ~1 per una barra letterale e ~0 per una tilde letterale. È ciò con cui puntano gli errori di JSON Schema e le operazioni di JSON Patch. Se conosci l’indirizzo, usa un Pointer.

jq è un linguaggio completo di elaborazione di flussi con un proprio modello dei valori, aritmetica, variabili e formattazione dell’output. Trasforma; JSONPath seleziona soltanto. Se la tua espressione comincia a costruire nuovi oggetti, quello che vuoi è jq.

JMESPath sta in mezzo: un linguaggio di interrogazione specificato, più vecchio della RFC 9535, con proiezioni e una propria libreria di funzioni, e una sintassi abbastanza vicina a JSONPath da confondere e abbastanza diversa da rompere. Sceglietene uno per codebase.

Per il caso quotidiano, ridurre un payload ai campi che ti interessano, lo strumento di filtro lo fa senza alcun linguaggio di espressioni, e il visualizzatore ti mostra la forma che stai interrogando. Se quello che vuoi è sapere che cosa è cambiato fra due payload, quello è diff, non una query.