Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Install dependencies:
pip install -r requirements.txt
```

To update requirements.txt, use https://azurda.github.io/

## Test

```bash
Expand Down
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The ISC License

Copyright (c) 2024 by Jos de Jong
Copyright (c) 2024-2025 by Jos de Jong

Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.

Expand Down
100 changes: 90 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,22 @@ Where:
- `data` is a JSON object or array
- `query` is a JSON query or string containing a text query
- `options` is an optional object which can have the following options:
- `functions` an object with custom functions
- `functions` an object with custom functions, see section [Custom functions](#custom-functions).
- `operators` a list with custom operators, see section [Custom operators](#custom-operators).

Example:

```python
from pprint import pprint
from jsonquerylang import jsonquery

input = [
data = [
{"name": "Chris", "age": 23, "scores": [7.2, 5, 8.0]},
{"name": "Joe", "age": 32, "scores": [6.1, 8.1]},
{"name": "Emily", "age": 19},
]
query = ["sort", ["get", "age"], "desc"]
output = jsonquery(input, query)
output = jsonquery(data, query)
pprint(output)
# [{'age': 32, 'name': 'Joe', 'scores': [6.1, 8.1]},
# {'age': 23, 'name': 'Chris', 'scores': [7.2, 5, 8.0]},
Expand All @@ -118,7 +119,7 @@ Where:

- `query` is a JSON query or string containing a text query
- `options` is an optional object which can have the following options:
- `functions` an object with custom functions
- `functions` an object with custom functions, see section [Custom functions](#custom-functions).

The function returns a lambda function which can be executed by passing JSON data as first argument.

Expand All @@ -128,14 +129,14 @@ Example:
from pprint import pprint
from jsonquerylang import compile

input = [
data = [
{"name": "Chris", "age": 23, "scores": [7.2, 5, 8.0]},
{"name": "Joe", "age": 32, "scores": [6.1, 8.1]},
{"name": "Emily", "age": 19},
]
query = ["sort", ["get", "age"], "desc"]
queryMe = compile(query)
output = queryMe(input)
output = queryMe(data)
pprint(output)
# [{'age': 32, 'name': 'Joe', 'scores': [6.1, 8.1]},
# {'age': 23, 'name': 'Chris', 'scores': [7.2, 5, 8.0]},
Expand All @@ -156,8 +157,8 @@ Where:

- `textQuery`: A query in text format
- `options`: An optional object which can have the following properties:
- `functions` an object with custom functions
- `operators` an object with the names of custom operators both as key and value
- `functions` an object with custom functions, see section [Custom functions](#custom-functions).
- `operators` a list with custom operators, see section [Custom operators](#custom-operators)

Example:

Expand Down Expand Up @@ -189,8 +190,8 @@ Where:

- `query` is a JSON Query
- `options` is an optional object that can have the following properties:
- `operators` an object with the names of custom operators both as key and value
- `indentation` a string containing the desired indentation, defaults to two spaces: `" "`
- `operators` a list with custom operators, see section [Custom operators](#custom-operators).
- `indentation` a string containing the desired indentation, defaults to two spaces: `" "`.
- `max_line_length` a number with the maximum line length, used for wrapping contents. Default value: `40`.

Example:
Expand All @@ -210,6 +211,85 @@ print(textQuery)
# '.friends | filter(.city == "new York") | sort(.age) | pick(.name, .age)'
```

## Custom functions

The functions `jsonquery`, `compile`, and `parse` accept custom functions. Custom functions are passed as an object with the key being the function name, and the value being a factory function.

Here is a minimal example which adds a function `times` to JSON Query:

```python
from jsonquerylang import jsonquery, JsonQueryOptions


def fn_times(value):
return lambda array: list(map(lambda item: item * value, array))


data = [2, 3, 8]
query = 'times(2)'
options: JsonQueryOptions = {"functions": {"times": fn_times}}

print(jsonquery(data, query, options))
# [4, 6, 16]
```

In the example above, the argument `value` is static. When the parameters are not static, the function `compile` can be used to compile them. For example, the function filter is implemented as follows:

```python
from jsonquerylang import compile, JsonQueryOptions

def truthy(value):
return value not in [False, 0, None]

def fn_filter(predicate):
_predicate = compile(predicate)

return lambda data: list(filter(lambda item: truthy(_predicate(item)), data))

options: JsonQueryOptions = {"functions": {"filter": fn_filter}}
```

You can have a look at the source code of the functions in [`/jsonquerylang/functions.py`](/jsonquerylang/functions.py) for more examples.

## Custom operators

The functions `jsonquery`, `parse`, and `stringify` accept custom operators. Custom operators are passed as a list with operators definitions. In practice, often both a custom operator and a corresponding custom function are configured. Each custom operator is an object with:

- Two required properties `name` and `op` to specify the function name and operator name, for example `{ "name": "add", "op": "+", ... }`
- One of the three properties `at`, `before`, or `after`, specifying the precedence compared to an existing operator.
- optionally, the property `left_associative` can be set to `True` to allow using a chain of multiple operators without parenthesis, like `a and b and c`. Without this, an exception will be thrown, which can be solved by using parenthesis like `(a and b) and c`.
- optionally, the property `vararg` can be set to `True` when the function supports a variable number of arguments, like `and(a, b, c, ...)`. In that case, a chain of operators like `a and b and c` will be parsed into the JSON Format `["and", a, b, c, ...]`. Operators that do not support variable arguments, like `1 + 2 + 3`, will be parsed into a nested JSON Format like `["add", ["add", 1, 2], 3]`.

Here is a minimal example configuring a custom operator `~=` and a corresponding function `aboutEq`:

```python
from jsonquerylang import jsonquery, compile, JsonQueryOptions


def about_eq(a, b):
epsilon = 0.001
a_compiled = compile(a, options)
b_compiled = compile(b, options)

return lambda data: abs(a_compiled(data) - b_compiled(data)) < epsilon


options: JsonQueryOptions = {
"functions": {"aboutEq": about_eq},
"operators": [{"name": "aboutEq", "op": "~=", "at": "=="}],
}

scores = [
{"name": "Joe", "score": 2.0001, "previousScore": 1.9999},
{"name": "Sarah", "score": 3, "previousScore": 1.5},
]
query = "filter(.score ~= .previousScore)"
unchanged_scores = jsonquery(scores, query, options)

print(unchanged_scores)
# [{'name': 'Joe', 'score': 2.0001, 'previousScore': 1.9999}]
```

## License

Released under the [ISC license](LICENSE.md).
1 change: 1 addition & 0 deletions example4_custom_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ def times(value):
options: JsonQueryOptions = {"functions": {"times": times}}

print(jsonquery(data, query, options))
# [4, 6, 16]
25 changes: 25 additions & 0 deletions example5_custom_operators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from jsonquerylang import jsonquery, compile, JsonQueryOptions


def about_eq(a, b):
epsilon = 0.001
a_compiled = compile(a, options)
b_compiled = compile(b, options)

return lambda data: abs(a_compiled(data) - b_compiled(data)) < epsilon


options: JsonQueryOptions = {
"functions": {"aboutEq": about_eq},
"operators": [{"name": "aboutEq", "op": "~=", "at": "=="}],
}

scores = [
{"name": "Joe", "score": 2.0001, "previousScore": 1.9999},
{"name": "Sarah", "score": 3, "previousScore": 1.5},
]
query = "filter(.score ~= .previousScore)"
unchanged_scores = jsonquery(scores, query, options)

print(unchanged_scores)
# [{'name': 'Joe', 'score': 2.0001, 'previousScore': 1.9999}]
2 changes: 1 addition & 1 deletion jsonquerylang/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from jsonquerylang.jsonquery import jsonquery
from jsonquerylang.compile import compile
from jsonquerylang.compile import compile, build_function
from jsonquerylang.stringify import stringify
from jsonquerylang.parse import parse
from jsonquerylang.types import (
Expand Down
12 changes: 11 additions & 1 deletion jsonquerylang/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ def compile(
:return: Returns a function which can execute the query
"""

functions = get_functions(lambda q: compile(q, options), build_function)

custom_functions: Final = (options.get("functions") if options else None) or {}
all_functions: Final = {**functions, **custom_functions}

Expand All @@ -57,4 +59,12 @@ def compile(
return lambda _: query


functions = get_functions(compile)
def build_function(fn):
def evaluate_fn(*args):
compiled_args = list(map(compile, args))

return lambda data: fn(
*list(map(lambda compiled_arg: compiled_arg(data), compiled_args))
)

return evaluate_fn
16 changes: 3 additions & 13 deletions jsonquerylang/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,7 @@
import re


def get_functions(compile):
def build_function(fn):
def evaluate_fn(*args):
compiled_args = list(map(compile, args))

return lambda data: fn(
*list(map(lambda compiled_arg: compiled_arg(data), compiled_args))
)

return evaluate_fn

def get_functions(compile, build_function):
def fn_get(*path: []):
def getter(item):
value = item
Expand Down Expand Up @@ -175,8 +165,8 @@ def key_by(data):
fn_min = lambda: lambda data: min(data)
fn_max = lambda: lambda data: max(data)

fn_and = build_function(lambda a, b: a and b)
fn_or = build_function(lambda a, b: a or b)
fn_and = build_function(lambda *args: reduce(lambda a, b: a and b, args))
fn_or = build_function(lambda *args: reduce(lambda a, b: a or b, args))
fn_not = build_function(lambda a: not a)

def fn_exists(query_get):
Expand Down
55 changes: 55 additions & 0 deletions jsonquerylang/operators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from functools import reduce
from typing import Mapping
from jsonquerylang.types import OperatorGroup, CustomOperator
from jsonquerylang.utils import find_index

operators: list[OperatorGroup] = [
{"pow": "^"},
{"multiply": "*", "divide": "/", "mod": "%"},
{"add": "+", "subtract": "-"},
{"gt": ">", "gte": ">=", "lt": "<", "lte": "<=", "in": "in", "not in": "not in"},
{"eq": "==", "ne": "!="},
{"and": "and"},
{"or": "or"},
{"pipe": "|"},
]

vararg_operators = ["|", "and", "or"]

left_associative_operators = ["|", "and", "or", "*", "/", "%", "+", "-"]


def extend_operators(
all_operators: list[OperatorGroup], custom_operators: list[CustomOperator]
) -> list[OperatorGroup]:
# backward compatibility error with v4 where `operators` was an object
if type(custom_operators) is not list:
raise RuntimeError("Invalid custom operators")

return reduce(extend_operator, custom_operators, all_operators)


def extend_operator(
all_operators: list[OperatorGroup], custom_operator: CustomOperator
) -> list[OperatorGroup]:
name = custom_operator.get("name")
op = custom_operator.get("op")
at = custom_operator.get("at")
after = custom_operator.get("after")
before = custom_operator.get("before")

if at:
callback = lambda group: {**group, name: op} if at in group.values() else group

return list(map(callback, all_operators))

search_op = after or before
index = find_index(lambda group: search_op in group.values(), all_operators)
if index != -1:
updated_operators = all_operators.copy()
new_group: Mapping[str, str] = {name: op}
updated_operators.insert(index + (1 if after else 0), new_group)

return updated_operators

raise RuntimeError("Invalid custom operator")
Loading