From 43726447a03aab461019f424672e8f08f24a07cb Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Tue, 13 Oct 2026 15:42:05 -0500 Subject: [PATCH] fix(json): make JSON path annotations and value comparisons portable The same JSON path query could give different results on MySQL and PostgreSQL in two ways. Type annotations (#1563). `Doc.proj(n="data.n:int")` worked on PostgreSQL but was a syntax error on MySQL, because the MySQL adapter passed the annotation straight into `JSON_VALUE ... RETURNING`, which accepts only its own short list of types. The MySQL adapter now maps the common PostgreSQL spellings onto that list: `int`, `integer`, `smallint`, `bigint`, `bool` and `boolean` to `signed`; `numeric(p,s)` to `decimal(p,s)`; `varchar(n)` and `text` to `char(n)` and `char`; `double precision` to `double`; `timestamp` to `datetime`. In the other direction, the PostgreSQL adapter now maps `datetime` to `timestamp`, the one RETURNING type it rejected. Spellings that already worked pass through unchanged, so nothing that ran before changes. Value comparisons (#1564). In a mapping restriction such as `{"s.cal": True}` or `{"s.ch": 64}`, the extracted JSON value is text. PostgreSQL refused to compare text with a bool or a number. MySQL compared `'true'` with `1` and silently matched nothing. Bools and numbers (numpy scalars and Decimal included) are now compared as JSON values: `json_extract(...) = cast('64' as json)` on MySQL, `jsonb_extract_path(...) = '64'::jsonb` on PostgreSQL. Why compare JSON values rather than use either approach suggested in the issue: - Rendering the value as text breaks `64` against a stored `64.0`, and `64.0` against a stored `64`, because the texts differ. It would also have broken those matches on MySQL, where numeric coercion makes them work today. - Casting the extracted text to the value's type raises on PostgreSQL for any row that holds a non-numeric string at that path (`'abc'::numeric`), so one odd row would break the whole query. - JSON equality compares numbers numerically on both servers (64 = 64.0, and large integers stay exact). It matches a boolean only to a JSON boolean, and a value of another JSON type is unequal rather than an error. One behavior change: on MySQL, a number no longer matches a JSON string holding the same digits (`{"s.ch": 64}` against `"64"`), which it used to do through text coercion. PostgreSQL never matched this case; it raised. Strings still compare as text, so `{"s.ch": "64"}` and `{"s.cal": "true"}` work as before, and an annotated key such as `{"s.n:int": 7}` still compares the cast value. NaN and infinity raise a DataJointError, since JSON cannot represent them. The new integration tests run on both backends. They cover restrictions by bool, int, float, numpy scalars, Decimal and string, including against stored floats, non-numeric strings and missing keys, and restrictions and projections with each annotation spelling. 42 of the 90 cases fail on the previous code. Closes #1563. Closes #1564. --- src/datajoint/adapters/base.py | 37 ++++++- src/datajoint/adapters/mysql.py | 68 +++++++++++- src/datajoint/adapters/postgres.py | 56 +++++++--- src/datajoint/condition.py | 40 +++++++ tests/integration/test_json_path.py | 161 ++++++++++++++++++++++++++++ tests/unit/test_adapters.py | 65 +++++++++++ tests/unit/test_condition.py | 37 ++++++- 7 files changed, 447 insertions(+), 17 deletions(-) create mode 100644 tests/integration/test_json_path.py diff --git a/src/datajoint/adapters/base.py b/src/datajoint/adapters/base.py index 5b4a7ab72..be28e5b16 100644 --- a/src/datajoint/adapters/base.py +++ b/src/datajoint/adapters/base.py @@ -1138,7 +1138,12 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None) path : str JSON path (e.g., 'field' or 'nested.field'). return_type : str, optional - Return type specification (MySQL-specific). + Type to cast the extracted value to. Every backend accepts MySQL's + ``JSON_VALUE ... RETURNING`` spellings (``signed``, ``unsigned``, + ``decimal(p,s)``, ``char(n)``, ``double``, ``datetime``, ...) and the + common PostgreSQL spellings (``int``, ``integer``, ``bigint``, + ``numeric(p,s)``, ``varchar(n)``, ``text``, ``boolean``, + ``timestamp``, ...). Returns ------- @@ -1152,6 +1157,36 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None) """ ... + @abstractmethod + def json_path_equals(self, column: str, path: str, value: str) -> str: + """ + Generate a condition comparing the JSON value at a path with a JSON literal. + + The comparison is between JSON values, not text: numbers compare + numerically (``64`` equals ``64.0``), booleans match only JSON booleans, + and a value of another JSON type (e.g. a string) is simply unequal. + + Parameters + ---------- + column : str + Column name containing JSON data. + path : str + JSON path (e.g., 'field' or 'nested.field'). + value : str + JSON literal (e.g., '64', '2.5', 'true'). + + Returns + ------- + str + Database-specific SQL condition. + + Examples + -------- + MySQL: json_extract(`column`, _utf8mb4'$.path') = cast('64' as json) + PostgreSQL: jsonb_extract_path("column", 'path') = '64'::jsonb + """ + ... + def translate_expression(self, expr: str) -> str: """ Translate SQL expression for backend compatibility. diff --git a/src/datajoint/adapters/mysql.py b/src/datajoint/adapters/mysql.py index 2ef9baf84..40cfa3d8f 100644 --- a/src/datajoint/adapters/mysql.py +++ b/src/datajoint/adapters/mysql.py @@ -7,6 +7,7 @@ from __future__ import annotations +import re from typing import Any import pymysql as client @@ -44,6 +45,25 @@ "date": "date", } +# JSON path type annotations (``attr.path:type``) that PostgreSQL accepts but +# MySQL's ``JSON_VALUE ... RETURNING`` does not, mapped onto the RETURNING +# vocabulary so the same annotation works on both backends. Annotations not +# listed here are passed through unchanged. +JSON_RETURN_TYPE_MAP = { + "int": "signed", + "integer": "signed", + "smallint": "signed", + "bigint": "signed", + "bool": "signed", + "boolean": "signed", + "numeric": "decimal", + "double precision": "double", + "varchar": "char", + "text": "char", + "timestamp": "datetime", +} +_JSON_RETURN_TYPE_PATTERN = re.compile(r"\s*(?P[a-z]+(?:\s+[a-z]+)?)\s*(?P\(.*\))?\s*") + class MySQLAdapter(DatabaseAdapter): """MySQL database adapter implementation.""" @@ -856,7 +876,9 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None) path : str JSON path (e.g., 'field' or 'nested.field'). return_type : str, optional - Return type specification (e.g., 'decimal(10,2)'). + Return type specification (e.g., 'decimal(10,2)'). PostgreSQL + spellings such as 'int' or 'numeric(10,2)' are mapped onto the + RETURNING vocabulary (see ``JSON_RETURN_TYPE_MAP``). Returns ------- @@ -869,11 +891,53 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None) "json_value(`data`, _utf8mb4'$.field')" >>> adapter.json_path_expr('data', 'value', 'decimal(10,2)') "json_value(`data`, _utf8mb4'$.value' returning decimal(10,2))" + >>> adapter.json_path_expr('data', 'n', 'int') + "json_value(`data`, _utf8mb4'$.n' returning signed)" """ quoted_col = self.quote_identifier(column) - return_clause = f" returning {return_type}" if return_type else "" + return_clause = f" returning {self._json_return_type(return_type)}" if return_type else "" return f"json_value({quoted_col}, _utf8mb4'$.{path}'{return_clause})" + @staticmethod + def _json_return_type(return_type: str) -> str: + """Map a JSON path type annotation onto MySQL's RETURNING vocabulary.""" + match = _JSON_RETURN_TYPE_PATTERN.fullmatch(return_type.lower()) + if match is None: + return return_type + name = " ".join(match["name"].split()) + if name not in JSON_RETURN_TYPE_MAP: + return return_type + mysql_type = JSON_RETURN_TYPE_MAP[name] + # Integer display widths (e.g. int(11)) have no RETURNING equivalent; other + # arguments (precision, scale, length) carry over. + return mysql_type if mysql_type == "signed" else mysql_type + (match["args"] or "") + + def json_path_equals(self, column: str, path: str, value: str) -> str: + """ + Generate a condition comparing the JSON value at a path with a JSON literal. + + Parameters + ---------- + column : str + Column name containing JSON data. + path : str + JSON path (e.g., 'field' or 'nested.field'). + value : str + JSON literal (e.g., '64', '2.5', 'true'). + + Returns + ------- + str + MySQL condition comparing two JSON values. + + Examples + -------- + >>> adapter.json_path_equals('data', 'n', '64') + "json_extract(`data`, _utf8mb4'$.n') = cast('64' as json)" + """ + quoted_col = self.quote_identifier(column) + return f"json_extract({quoted_col}, _utf8mb4'$.{path}') = cast('{value}' as json)" + def translate_expression(self, expr: str) -> str: """ Translate SQL expression for MySQL compatibility. diff --git a/src/datajoint/adapters/postgres.py b/src/datajoint/adapters/postgres.py index 3a55d8eb8..938ab7259 100644 --- a/src/datajoint/adapters/postgres.py +++ b/src/datajoint/adapters/postgres.py @@ -1103,6 +1103,23 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None) 'jsonb_extract_path_text("data", \\'value\\')::float' """ quoted_col = self.quote_identifier(column) + expr = f"jsonb_extract_path_text({quoted_col}, {self._json_path_args(path)})" + # Add cast if return type specified + if return_type: + # Map DataJoint types to PostgreSQL types + pg_type = return_type.lower() + if pg_type in ("unsigned", "signed"): + pg_type = "integer" + elif pg_type == "double": + pg_type = "double precision" + elif pg_type.startswith("datetime"): + pg_type = "timestamp" + pg_type[len("datetime") :] + expr = f"({expr})::{pg_type}" + return expr + + @staticmethod + def _json_path_args(path: str) -> str: + """Split a JSON path into quoted jsonb_extract_path() arguments.""" # Split path by '.' for nested access, handling array notation path_parts = [] for part in path.split("."): @@ -1115,18 +1132,33 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None) path_parts.extend(indices) else: path_parts.append(part) - path_args = ", ".join(f"'{part}'" for part in path_parts) - expr = f"jsonb_extract_path_text({quoted_col}, {path_args})" - # Add cast if return type specified - if return_type: - # Map DataJoint types to PostgreSQL types - pg_type = return_type.lower() - if pg_type in ("unsigned", "signed"): - pg_type = "integer" - elif pg_type == "double": - pg_type = "double precision" - expr = f"({expr})::{pg_type}" - return expr + return ", ".join(f"'{part}'" for part in path_parts) + + def json_path_equals(self, column: str, path: str, value: str) -> str: + """ + Generate a condition comparing the JSON value at a path with a JSON literal. + + Parameters + ---------- + column : str + Column name containing JSON data. + path : str + JSON path (e.g., 'field' or 'nested.field'). + value : str + JSON literal (e.g., '64', '2.5', 'true'). + + Returns + ------- + str + PostgreSQL condition comparing two jsonb values. + + Examples + -------- + >>> adapter.json_path_equals('data', 'n', '64') + 'jsonb_extract_path("data", \\'n\\') = \\'64\\'::jsonb' + """ + quoted_col = self.quote_identifier(column) + return f"jsonb_extract_path({quoted_col}, {self._json_path_args(path)}) = '{value}'::jsonb" def translate_expression(self, expr: str) -> str: """ diff --git a/src/datajoint/condition.py b/src/datajoint/condition.py index f5789348a..5f74e7b68 100644 --- a/src/datajoint/condition.py +++ b/src/datajoint/condition.py @@ -13,6 +13,7 @@ import inspect import json import logging +import math import re import uuid from dataclasses import dataclass @@ -66,6 +67,40 @@ def translate_attribute(key: str, adapter=None) -> tuple[dict | None, str]: ) +# Python values that a JSON path restriction compares as JSON values, not as text +_JSON_SCALAR_TYPES = (bool, numpy.bool_, int, numpy.integer, float, numpy.floating, decimal.Decimal) + + +def _json_literal(value: Any) -> str: + """ + Render a bool or number as a JSON literal for a JSON path comparison. + + Parameters + ---------- + value : bool, int, float, or decimal.Decimal + Value to render (numpy scalars included). + + Returns + ------- + str + JSON literal, e.g. ``'true'``, ``'64'``, ``'2.5'``. + + Raises + ------ + DataJointError + If the value is NaN or infinite, which JSON cannot represent. + """ + if isinstance(value, (bool, numpy.bool_)): + return "true" if value else "false" + if isinstance(value, (int, numpy.integer)): + return str(int(value)) + if isinstance(value, decimal.Decimal) and value.is_finite(): + return str(value) + if isinstance(value, (float, numpy.floating)) and math.isfinite(value): + return json.dumps(float(value)) + raise DataJointError(f"Cannot restrict a JSON path by the non-finite number {value}.") + + class PromiscuousOperand: """ Wrapper to bypass join compatibility checking. @@ -344,6 +379,11 @@ def prep_value(k, v): return f"{k}='{json.dumps(v)}'" if v is None: return f"{k} IS NULL" + if is_json_path and not key_match["type"] and isinstance(v, _JSON_SCALAR_TYPES): + # The extracted value is text: PostgreSQL refuses to compare it with a + # bool or number, and MySQL matches no bool. Compare JSON values instead, + # which gives the same result on both (64 matches 64.0, true only true). + return adapter.json_path_equals(key_match["attr"], key_match["path"], _json_literal(v)) if key_match is not None and query_expression.heading[key_match["attr"]].uuid: if not isinstance(v, uuid.UUID): try: diff --git a/tests/integration/test_json_path.py b/tests/integration/test_json_path.py new file mode 100644 index 000000000..edc41d982 --- /dev/null +++ b/tests/integration/test_json_path.py @@ -0,0 +1,161 @@ +""" +JSON path restrictions and type annotations give the same result on MySQL and +PostgreSQL. + +- A mapping restriction on a JSON path compares bool and number values as JSON + values, so ``True`` and ``64`` work on both backends and ``64`` matches a + stored ``64.0`` (#1564). +- A ``attr.path:type`` annotation accepts the same type spellings on both + backends (#1563). +""" + +import datetime +import decimal + +import numpy as np +import pytest + +import datajoint as dj + + +@pytest.fixture +def Device(connection_by_backend, backend, prefix): + schema = dj.Schema(f"{prefix}_json_path_{backend}", connection=connection_by_backend) + + @schema + class Device(dj.Manual): + definition = """ + name : varchar(32) + --- + s : json + """ + + Device.insert( + [ + dict( + name="a", + s=dict(vendor="Acme", ch=64, gain=2.5, cal=True, n=7, made="2024-01-02 03:04:05", pos=[1, 2]), + ), + dict( + name="b", + s=dict(vendor="Other", ch=16.0, gain=0.1, cal=False, n=3, made="2025-06-07 08:09:10", pos=[3, 4]), + ), + # Strings where the other rows hold numbers + dict(name="c", s=dict(vendor="64", ch="sixty-four")), + ], + skip_duplicates=True, + ) + yield Device + schema.drop() + + +def names(query): + return sorted(query.to_arrays("name")) + + +@pytest.mark.parametrize( + "restriction, expected", + [ + # strings compare as text, as before + ({"s.vendor": "Acme"}, ["a"]), + ({"s.ch": "64"}, ["a"]), + ({"s.cal": "true"}, ["a"]), + # bool + ({"s.cal": True}, ["a"]), + ({"s.cal": False}, ["b"]), + ({"s.cal": np.bool_(True)}, ["a"]), + # int, including against a stored float and a stored non-numeric string + ({"s.ch": 64}, ["a"]), + ({"s.ch": 16}, ["b"]), + ({"s.ch": np.int64(64)}, ["a"]), + ({"s.n": 7}, ["a"]), + # float, including against a stored int + ({"s.ch": 64.0}, ["a"]), + ({"s.gain": 2.5}, ["a"]), + ({"s.gain": 0.1}, ["b"]), + ({"s.gain": np.float64(2.5)}, ["a"]), + ({"s.gain": decimal.Decimal("2.50")}, ["a"]), + # a number or bool never matches a JSON string, a missing key never matches + ({"s.vendor": 64}, []), + ({"s.vendor": True}, []), + ({"s.missing": 1}, []), + # array element + ({"s.pos[1]": 4}, ["b"]), + # combined with a regular attribute + ({"name": "a", "s.cal": True, "s.ch": 64}, ["a"]), + ({"name": "b", "s.cal": True}, []), + ], +) +def test_restrict_by_value(Device, restriction, expected): + assert names(Device & restriction) == expected + + +def test_anti_restrict(Device): + # Row c holds a string at s.ch, which is unequal to 64 rather than an error + assert names(Device - {"s.ch": 64}) == ["b", "c"] + # Row c has no s.cal, so it matches neither the restriction nor its complement + assert names(Device - {"s.cal": True}) == ["b"] + + +@pytest.mark.parametrize( + "restriction, expected", + [ + ({"s.n:int": 7}, ["a"]), + ({"s.n:signed": 3}, ["b"]), + ({"s.n:unsigned": 7}, ["a"]), + ({"s.cal:boolean": True}, ["a"]), + ({"s.cal:bool": False}, ["b"]), + ({"s.made:datetime": datetime.datetime(2024, 1, 2, 3, 4, 5)}, ["a"]), + ({"s.made:timestamp": datetime.datetime(2025, 6, 7, 8, 9, 10)}, ["b"]), + ], +) +def test_restrict_by_annotated_path(Device, restriction, expected): + assert names(Device & restriction) == expected + + +@pytest.mark.parametrize( + "annotation, expected", + [ + ("int", [7, 3]), + ("integer", [7, 3]), + ("bigint", [7, 3]), + ("smallint", [7, 3]), + ("signed", [7, 3]), + ("unsigned", [7, 3]), + ], +) +def test_project_integer_annotation(Device, annotation, expected): + rows = (Device & "name in ('a', 'b')").proj(n=f"s.n:{annotation}").to_dicts(order_by="name") + assert [row["n"] for row in rows] == expected + assert all(isinstance(row["n"], (int, np.integer)) for row in rows) + + +@pytest.mark.parametrize("annotation", ["decimal(4,1)", "numeric(4,1)"]) +def test_project_decimal_annotation(Device, annotation): + rows = Device.proj(g=f"s.gain:{annotation}").to_dicts(order_by="name") + assert [row["g"] for row in rows[:2]] == [decimal.Decimal("2.5"), decimal.Decimal("0.1")] + + +@pytest.mark.parametrize("annotation", ["double", "double precision"]) +def test_project_double_annotation(Device, annotation): + rows = (Device & "name in ('a', 'b')").proj(g=f"s.gain:{annotation}").to_dicts(order_by="name") + assert [row["g"] for row in rows] == [2.5, 0.1] + + +@pytest.mark.parametrize("annotation", ["varchar(16)", "text"]) +def test_project_string_annotation(Device, annotation): + rows = Device.proj(v=f"s.vendor:{annotation}").to_dicts(order_by="name") + assert [row["v"] for row in rows] == ["Acme", "Other", "64"] + + +@pytest.mark.parametrize("annotation", ["bool", "boolean"]) +def test_project_boolean_annotation(Device, annotation): + rows = (Device & "name in ('a', 'b')").proj(c=f"s.cal:{annotation}").to_dicts(order_by="name") + # PostgreSQL returns True/False, MySQL 1/0 + assert [row["c"] for row in rows] == [True, False] + + +@pytest.mark.parametrize("annotation", ["datetime", "timestamp"]) +def test_project_datetime_annotation(Device, annotation): + rows = (Device & "name in ('a', 'b')").proj(m=f"s.made:{annotation}").to_dicts(order_by="name") + assert [row["m"] for row in rows] == [datetime.datetime(2024, 1, 2, 3, 4, 5), datetime.datetime(2025, 6, 7, 8, 9, 10)] diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index bd0afa9e0..d0eccbbce 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -181,6 +181,46 @@ def test_json_path_expr_with_return_type(self, adapter): result = adapter.json_path_expr("data", "value", "decimal(10,2)") assert result == "json_value(`data`, _utf8mb4'$.value' returning decimal(10,2))" + @pytest.mark.parametrize( + "annotation, returning", + [ + # PostgreSQL spellings are mapped onto the RETURNING vocabulary + ("int", "signed"), + ("INTEGER", "signed"), + ("bigint", "signed"), + ("smallint", "signed"), + ("int(11)", "signed"), + ("bool", "signed"), + ("boolean", "signed"), + ("numeric", "decimal"), + ("numeric(5,1)", "decimal(5,1)"), + ("double precision", "double"), + ("varchar(16)", "char(16)"), + ("text", "char"), + ("timestamp", "datetime"), + # RETURNING spellings pass through unchanged + ("signed", "signed"), + ("unsigned", "unsigned"), + ("decimal(4, 1)", "decimal(4, 1)"), + ("char(20)", "char(20)"), + ("float", "float"), + ("double", "double"), + ("datetime", "datetime"), + ], + ) + def test_json_path_expr_maps_return_type(self, adapter, annotation, returning): + """PostgreSQL type spellings in a JSON path annotation are valid on MySQL (#1563).""" + result = adapter.json_path_expr("data", "n", annotation) + assert result == f"json_value(`data`, _utf8mb4'$.n' returning {returning})" + + def test_json_path_equals(self, adapter): + """Test JSON path comparison with a JSON literal.""" + assert adapter.json_path_equals("data", "n", "64") == "json_extract(`data`, _utf8mb4'$.n') = cast('64' as json)" + assert ( + adapter.json_path_equals("data", "a[0].b", "true") + == "json_extract(`data`, _utf8mb4'$.a[0].b') = cast('true' as json)" + ) + def test_transaction_sql(self, adapter): """Test transaction statements.""" assert "START TRANSACTION" in adapter.start_transaction_sql() @@ -326,6 +366,30 @@ def test_json_path_expr_nested(self, adapter): result = adapter.json_path_expr("data", "nested.field") assert result == "jsonb_extract_path_text(\"data\", 'nested', 'field')" + @pytest.mark.parametrize( + "annotation, cast", + [ + ("signed", "integer"), + ("unsigned", "integer"), + ("double", "double precision"), + ("datetime", "timestamp"), + ("datetime(3)", "timestamp(3)"), + ("int", "int"), + ("decimal(5,1)", "decimal(5,1)"), + ], + ) + def test_json_path_expr_maps_return_type(self, adapter, annotation, cast): + """MySQL RETURNING spellings in a JSON path annotation are valid on PostgreSQL.""" + result = adapter.json_path_expr("data", "n", annotation) + assert result == f"(jsonb_extract_path_text(\"data\", 'n'))::{cast}" + + def test_json_path_equals(self, adapter): + """Test JSON path comparison with a JSON literal.""" + assert adapter.json_path_equals("data", "n", "64") == "jsonb_extract_path(\"data\", 'n') = '64'::jsonb" + assert ( + adapter.json_path_equals("data", "a[0].b", "true") == "jsonb_extract_path(\"data\", 'a', '0', 'b') = 'true'::jsonb" + ) + def test_transaction_sql(self, adapter): """Test transaction statements.""" assert adapter.start_transaction_sql() == "BEGIN" @@ -405,6 +469,7 @@ def test_adapter_implements_interface(self, backend): "current_timestamp_expr", "interval_expr", "json_path_expr", + "json_path_equals", "format_column_definition", "table_options_clause", "table_comment_ddl", diff --git a/tests/unit/test_condition.py b/tests/unit/test_condition.py index 3200e34c4..89ea6e066 100644 --- a/tests/unit/test_condition.py +++ b/tests/unit/test_condition.py @@ -1,7 +1,12 @@ -"""Unit tests for condition.py - Top class and merge logic.""" +"""Unit tests for condition.py - Top class and merge logic, JSON path literals.""" +import decimal + +import numpy as np import pytest -from datajoint.condition import Top + +from datajoint.condition import Top, _json_literal +from datajoint.errors import DataJointError class TestTopMerge: @@ -93,3 +98,31 @@ def test_invalid_offset_type_raises(self): """Non-integer offset raises TypeError.""" with pytest.raises(TypeError): Top(offset="1") + + +class TestJsonLiteral: + """Tests for rendering restriction values as JSON literals (#1564).""" + + @pytest.mark.parametrize( + "value, literal", + [ + (True, "true"), + (False, "false"), + (np.bool_(True), "true"), + (64, "64"), + (np.int64(-3), "-3"), + (2**70, str(2**70)), + (64.0, "64.0"), + (0.1, "0.1"), + (np.float32(2.5), "2.5"), + (1e-7, "1e-07"), + (decimal.Decimal("20.50"), "20.50"), + ], + ) + def test_literal(self, value, literal): + assert _json_literal(value) == literal + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), np.float64("-inf"), decimal.Decimal("NaN")]) + def test_non_finite_raises(self, value): + with pytest.raises(DataJointError): + _json_literal(value)