Saltar al contenido
jsonbeautifiers
Español

JSONPath, la versión que sí está especificada

JSONPath fue una entrada de blog durante diecisiete años. La RFC 9535 por fin dice qué significa.

Cada afirmación de esta página está medida o tiene fuente. Cuando no es ninguna de las dos, lo dice.

JSONPath empezó en 2007 como una entrada de blog de Stefan Goessner, que esbozaba un lenguaje de consulta al estilo de XPath para JSON en unas dos pantallas de prosa y una implementación de referencia en JavaScript. Fue lo bastante bueno como para que todo el mundo lo implementara, y lo bastante vago como para que todo el mundo lo implementara de forma distinta. ¿El operador de descendencia .. considera el propio nodo raíz o solo sus hijos? ¿[-1] es el último elemento o un error? ¿Puede un corchete contener varios selectores? ¿Qué hace un filtro cuando la clave que prueba no existe? ¿Qué selecciona un slice con paso cero? Cada una de esas preguntas tenía al menos dos respuestas circulando, y el documento original no resolvía ninguna, porque partes de él se delegaban a cualquier eval() que hubiera a mano.

La RFC 9535 lo arregló en febrero de 2024. Es un Proposed Standard del IETF, el primer nivel de madurez de la vía de estándares: es una especificación real, estable y redactada normativamente, y no es un Internet Standard. Trátala como tratarías a cualquier Proposed Standard, como aquello contra lo que escribir código nuevo sabiendo que mucho código desplegado es anterior.

El documento

Todo lo de abajo se ejecuta contra esto:

{
  "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" }
    ]
  }
}

Fíjate en el último libro: sin price. En esa ausencia vive casi todo el comportamiento interesante.

Segmentos y selectores

Una consulta es $ seguido de una secuencia de segmentos. $ es la raíz. Cada segmento aplica uno o más selectores a cada nodo que se tiene en mano y produce una nueva lista de nodos. El resultado de una consulta es siempre una lista de nodos, incluso cuando contiene uno o ninguno, y por eso una biblioteca de JSONPath devuelve un array donde una de JSON Pointer devuelve un valor.

$.store.book[0].title        notación de punto, abreviatura de selectores de nombre
$['store']['book'][0]        notación de corchetes, mismo significado
$["store"]["book"][0]        las comillas dobles también valen

La notación de corchetes no es decoración opcional. $.first-name no es un selector de nombre válido, así que una clave con un guion, un espacio, un punto o un dígito inicial tiene que escribirse $['first-name']. La forma con corchetes y comillas simples es además la de una ruta normalizada, el identificador único que la RFC 9535 define para un solo nodo: $['store']['book'][0]['title'].

Los selectores:

  • Nombre: 'title' o "title", que selecciona un miembro de objeto. Nada sobre un array.
  • Comodín *: cada valor miembro de un objeto, cada elemento de un array. $.store.book[*] y $.store.book.* son la misma consulta.
  • Índice: un entero, base cero. Los negativos cuentan desde el final, así que [-1] es el último elemento. Eso ahora está especificado, no es una amabilidad de cada biblioteca.
  • Slice inicio:fin:paso: semiabierto, con el fin excluido, y un paso negativo camina hacia atrás. Un paso de 0 no selecciona nada en vez de lanzar error, que es el único punto donde la RFC se separa deliberadamente de Python.
  • Filtro ?expr: se ve abajo.

Un segmento hijo puede contener varios selectores separados por comas, y no tienen por qué ser del mismo tipo. $.store.book[0, -1] es el primer y el último libro; $.store.book[0, 2:4] mezcla un índice con un slice. Los resultados vuelven en orden de selector, así que una unión puede legítimamente devolver el mismo nodo dos veces.

Un segmento de descendencia se escribe con dos puntos: $..author, $..['author'], $..*, $..[0]. Visita el nodo de entrada y todos sus descendientes, y luego aplica sus selectores a cada uno. La vieja ambigüedad se ha ido: $..store sobre el documento de arriba sí coincide con $.store, porque el segmento de descendencia empieza en la propia raíz.

Filtros, que es donde están las preguntas

Dentro de un filtro, @ es el nodo actual que se está probando y $ sigue siendo la raíz del documento entero, así que un filtro puede comparar un valor con algo situado en otra parte del documento.

Una consulta desnuda usada como expresión de filtro es una prueba de existencia: es verdadera cuando la consulta selecciona al menos un nodo. $.store.book[[email protected]] selecciona los dos libros que tienen ISBN. Los operadores de comparación son ==, !=, <, <=, >, >=; los lógicos son &&, || y el prefijo !, con paréntesis para agrupar. Los paréntesis alrededor de toda la expresión están permitidos pero ya no son obligatorios, así que tanto [?(@.price < 10)] como [[email protected] < 10] son válidos y significan lo mismo.

Los operandos de comparación están restringidos. Cada lado debe ser un literal, una consulta singular (formada solo por selectores de nombre e índice, de modo que pueda seleccionar como mucho un nodo) o una llamada a función. @.price cumple. @..price y @.book[*].price no, y una implementación debería rechazar la consulta en vez de adivinar.

Ahora la regla que hace tropezar a la gente. Una consulta que no selecciona nada produce el valor especial Nothing, y Nothing no es null, ni cero, ni false. Compara igual con Nothing y con nada más, y toda comparación de orden que lo involucre es falsa. Consecuencias:

$.store.book[[email protected] < 10]     excluye el libro de Tolkien (sin price)
$.store.book[[email protected] >= 10]    también lo excluye
$.store.book[[email protected] == null]  también lo excluye: Nothing no es null
$.store.book[[email protected]]         lo selecciona, y solo a él

Así que la ausencia se prueba negando la prueba de existencia, y == null prueba un miembro que está presente y contiene null. La imagen especular es una sorpresa genuina: $.store.book[[email protected] == @.discount] selecciona el libro de Tolkien, porque ambos lados son Nothing y Nothing es igual a Nothing.

Una comparación entre dos tipos distintos nunca es un error. La igualdad entre tipos es simplemente falsa, y el orden solo está definido entre dos números o dos cadenas, así que @.price > "10" es falso para todos los libros.

Extensiones de función

Hay cinco definidas, y están tipadas, así que length(@.book[*]) es un error de tipo y no una sorpresa en tiempo de ejecución.

Función Toma Da
length() un valor valores escalares Unicode en una cadena, elementos en un array, miembros en un objeto, y Nothing en los demás casos
count() una lista de nodos cuántos nodos seleccionó
match() una cadena y una regex verdadero si la regex coincide con la cadena entera
search() una cadena y una regex verdadero si la regex coincide en cualquier parte de la cadena
value() una lista de nodos el valor, si la lista contiene exactamente un nodo, y Nothing en otro caso

count() existe porque una consulta no singular no puede ser operando de comparación, así que count(@.book[[email protected]]) == 2 es como se dice «tiene exactamente dos libros con ISBN». value() resuelve el mismo problema desde el otro lado: $[?value(@..name) == 'Corner Books'] funciona porque value() colapsa una consulta de varios nodos en un único valor comparable, o en Nothing si coincidió con más o menos de un nodo.

El dialecto de expresiones regulares es I-Regexp (RFC 9485), un subconjunto deliberadamente pequeño que se corresponde con las expresiones regulares de XSD. No es PCRE. No esperes lookaheads ni retrorreferencias, y recuerda que match(@.category, 'fic') es falso para "fiction" mientras que search(@.category, 'fic') es verdadero.

Lo que no está en el lenguaje

Tres cosas a las que la gente todavía recurre no existen. Las expresiones de script, la forma [(...)] de la entrada original, han desaparecido, y con ellas la dependencia de eval(). No hay operador de padre: una consulta solo camina hacia abajo, así que si necesitas el objeto contenedor seleccionas el objeto y filtras por el hijo. Y la pseudopropiedad length se ha ido, así que $.store.book[(@.length-1)] no es una consulta. Escribe $.store.book[-1].

Ejemplos resueltos

Expresión Resultado
$.store.name "Corner Books"
$.store.book[*].author los cuatro autores
$..isbn las dos cadenas 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[[email protected] < 10].title "Sayings of the Century", "Moby Dick"
$.store.book[[email protected]].title "The Lord of the Rings"
$.store.book[[email protected] > $.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"

Pega el documento y cualquiera de estas en el probador de JSONPath para ver la lista de nodos junto a la ruta normalizada de cada coincidencia, que es la forma más rápida de comprobar si tu biblioteca coincide con la RFC en índices negativos y en claves ausentes.

Cuándo usar otra cosa

JSONPath selecciona nodos. Ese es todo el trabajo, y hay otras tres herramientas de consulta que se solapan con él:

JSON Pointer (RFC 6901) direcciona exactamente un nodo, sin comodines, sin filtros y sin ambigüedad: /store/book/0/title, con ~1 para una barra literal y ~0 para una tilde literal. Es con lo que apuntan los errores de JSON Schema y las operaciones de JSON Patch. Si conoces la dirección, usa un Pointer.

jq es un lenguaje completo de procesamiento de flujos con su propio modelo de valores, aritmética, variables y formateo de salida. Transforma; JSONPath solo selecciona. Si tu expresión está empezando a construir objetos nuevos, lo que quieres es jq.

JMESPath queda entre los dos: un lenguaje de consulta especificado, más antiguo que la RFC 9535, con proyecciones y su propia biblioteca de funciones, y una sintaxis lo bastante parecida a JSONPath como para confundir y lo bastante distinta como para romper. Elige uno por base de código.

Para el caso cotidiano, recortar un payload hasta los campos que te importan, la herramienta de filtro lo hace sin lenguaje de expresiones alguno, y el visor te muestra la forma que estás consultando. Si lo que quieres es saber qué cambió entre dos payloads, eso es diff, no una consulta.