Skip to content
Open
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
90 changes: 89 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jinja2 = "3.1.5"
jsonschema = "3.1.1"
markupsafe = "2.1.1"
maxminddb-geolite2 = "2018.703"
orjson = "3.10.15"
parsedatetime = "2.4"
passlib = "1.7.3"
psycopg2-binary = "2.9.6"
Expand Down
52 changes: 31 additions & 21 deletions redash/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@
import hashlib
import io
import json
import math
import os
import random
import re
import sys
import uuid

import orjson
import pystache
import pytz
import sqlparse
from flask import current_app
from funcy import select_values
from sqlalchemy.orm.query import Query
Expand Down Expand Up @@ -111,7 +109,7 @@ def default(self, o):
elif isinstance(o, bytes):
result = binascii.hexlify(o).decode()
else:
result = super().default(o)
result = o # Pass object as it is to orjson.dumps
return result


Expand All @@ -121,26 +119,38 @@ def json_loads(data, *args, **kwargs):
return json.loads(data, *args, **kwargs)


# Convert NaN, Inf, and -Inf to None, as they are not valid JSON values.
def _sanitize_data(data):
if isinstance(data, dict):
return {k: _sanitize_data(v) for k, v in data.items()}
if isinstance(data, list):
return [_sanitize_data(v) for v in data]
if isinstance(data, float) and (math.isnan(data) or math.isinf(data)):
return None
return data
def _preprocess_json_data(data, encoder):
"""Recursively preprocess data for JSON serialization using JSONEncoder."""
if isinstance(data, (str, int, float, bool, type(None))):
return data
elif isinstance(data, dict):
return {_preprocess_json_data(k, encoder): _preprocess_json_data(v, encoder) for k, v in data.items()}
elif isinstance(data, (list, tuple)):
return [_preprocess_json_data(item, encoder) for item in data]
else:
return encoder.default(data)


def json_dumps(data, *args, **kwargs):
"""A custom JSON dumping function which passes all parameters to the
json.dumps function."""
kwargs.setdefault("cls", JSONEncoder)
kwargs.setdefault("ensure_ascii", False)
# Float value nan or inf in Python should be render to None or null in json.
# Using allow_nan = True will make Python render nan as NaN, leading to parse error in front-end
kwargs.setdefault("allow_nan", False)
return json.dumps(_sanitize_data(data), *args, **kwargs)
"""A custom JSON dump function which uses orjson for better performance."""

# Map common json.dumps kwargs to orjson options
options = orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS | orjson.OPT_UTC_Z
if kwargs.get("indent") == 2:
options |= orjson.OPT_INDENT_2

if kwargs.get("sort_keys"):
options |= orjson.OPT_SORT_KEYS

# orjson always uses compact separators (no equivalent to json.dumps(separators=...))
# orjson doesn't support skipkeys – invalid keys raise TypeError

try:
# Preprocess data before sending to orjson.dumps
preprocessed_data = _preprocess_json_data(data, JSONEncoder())
return orjson.dumps(preprocessed_data, option=options).decode("utf-8")
except orjson.JSONEncodeError as e:
raise TypeError(f"Object not serializable: {e}")


def mustache_render(template, context=None, **kwargs):
Expand Down
6 changes: 3 additions & 3 deletions tests/handlers/test_destinations.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def test_discord_notify_calls_requests_post():

mock_post.assert_called_once_with(
"https://discordapp.com/api/webhooks/test",
data=json.dumps(expected_payload),
data=json.dumps(expected_payload, separators=(",", ":")), # orjson.dumps always uses compact separators
headers={"Content-Type": "application/json"},
timeout=5.0,
)
Expand Down Expand Up @@ -248,7 +248,7 @@ def test_slack_notify_calls_requests_post():

mock_post.assert_called_once_with(
"https://slack.com/api/api.test",
data=json.dumps(expected_payload).encode(),
data=json.dumps(expected_payload, separators=(",", ":")).encode(),
timeout=5.0,
)

Expand Down Expand Up @@ -508,7 +508,7 @@ def test_datadog_notify_calls_requests_post():

mock_post.assert_called_once_with(
"https://api.datadoghq.com/api/v1/events",
data=json.dumps(expected_payload),
data=json.dumps(expected_payload, separators=(",", ":")),
headers={
"Accept": "application/json",
"Content-Type": "application/json",
Expand Down
6 changes: 3 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,17 @@ def test_list(self):
Id: 3
Name: Atest
Type: sqlite
Options: {"dbpath": "/tmp/test.db"}
Options: {"dbpath":"/tmp/test.db"}
--------------------
Id: 1
Name: test1
Type: pg
Options: {"dbname": "testdb1", "host": "example.com"}
Options: {"dbname":"testdb1","host":"example.com"}
--------------------
Id: 2
Name: test2
Type: sqlite
Options: {"dbpath": "/tmp/test.db"}
Options: {"dbpath":"/tmp/test.db"}
"""
self.assertMultiLineEqual(result.output, textwrap.dedent(expected_output).lstrip())

Expand Down
Loading