Skip to content

Commit 30b24a7

Browse files
roed314claude
andcommitted
Keep the stored literal of a raw decimal zero (LMFDB#1010)
raw_json_dumps special-cased RealNumber, but psycodict does not hand back a numeric that is exactly zero as one: numeric_converter returns an LmfdbDecimalZero, an integer wrapper keeping the literal in .literal. Such a value fell through to Json.prep, which sees a Sage integer and emits 0, dropping the stored scale and, for -0.000, the sign, whether the zero stood alone or sat inside a numeric[], a list or a dictionary. That contradicted the format's promise that a numeric is the decimal Postgres sent, copied verbatim. The literal lookup moves into exact_decimal(), which handles the integer wrapper alongside RealLiteral and RealNumber and validates the literal against the JSON number syntax once, so nothing is rebuilt from a float or an int. A numeric with no decimal point is still a Sage integer and is unaffected; NaN and the infinities still fall back to psycodict's extended encoding rather than emit an unparseable token. test_api.py gains test_raw_json_dumps, calling the serializer directly so the behaviour does not depend on finding a zero-valued row: 0.000, -0.000 and 1.250 come back verbatim, as do zeros nested in an array and in an object, and each result is re-parsed as JSON. The exact strings are the point, since Decimal("-0.000") == Decimal("0.000"). Verified: sage -python -m pytest lmfdb/api/test_api.py -> 8 passed; pyflakes and ruff clean; /api/lfunc_lfunctions/?_format=raw&_fields= mu_imag,analytic_normalization now gives [[0.0], 0] rather than [[0], 0]. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 182d10b commit 30b24a7

2 files changed

Lines changed: 51 additions & 9 deletions

File tree

lmfdb/api/api.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from sage.rings.real_mpfr import RealLiteral, RealNumber
77
from lmfdb.utils.psycopg_compat import QueryCanceledError
88
from lmfdb import db
9-
from psycodict.encoding import Json
9+
from psycodict.encoding import Json, LmfdbDecimalZero
1010
from lmfdb.utils import flash_error, comma
1111
from lmfdb.utils.datetime_utils import utc_now_naive
1212
from lmfdb.logger import logger
@@ -39,6 +39,28 @@ def pretty_document(rec, sep=", ", id=True):
3939
JSON_NUMBER_RE = re.compile(r"-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?\Z")
4040

4141

42+
def exact_decimal(value):
43+
"""
44+
The decimal that Postgres sent for a numeric value, as JSON number text.
45+
46+
psycodict hands a numeric back as a Sage real remembering the decimal it
47+
was built from, except for a decimal that is exactly zero, which it hands
48+
back as an integer wrapper remembering it instead. Either way the literal
49+
is the value, and rebuilding it from a float or an int would drop the
50+
stored scale, and with it the sign of a literal like -0.000.
51+
52+
None if there is no such literal, or if it is not JSON number syntax: NaN
53+
and the infinities have no JSON representation.
54+
"""
55+
if isinstance(value, (LmfdbDecimalZero, RealLiteral)):
56+
literal = value.literal
57+
elif isinstance(value, RealNumber):
58+
literal = str(value)
59+
else:
60+
return None
61+
return literal if JSON_NUMBER_RE.match(literal) else None
62+
63+
4264
def raw_json_dumps(value):
4365
"""
4466
The JSON text for one database value in the raw output format.
@@ -51,14 +73,10 @@ def raw_json_dumps(value):
5173
rounded. Values with no plain JSON rendering (rationals, number field
5274
elements, dates, ...) keep psycodict's extended encoding.
5375
"""
54-
if isinstance(value, RealNumber):
55-
literal = value.literal if isinstance(value, RealLiteral) else str(value)
56-
# NaN and the infinities have no JSON number syntax, so they fall
57-
# through to the extended encoding rather than produce a line that no
58-
# JSON reader will accept.
59-
if JSON_NUMBER_RE.match(literal):
60-
return literal
61-
elif isinstance(value, (list, tuple)):
76+
literal = exact_decimal(value)
77+
if literal is not None:
78+
return literal
79+
if isinstance(value, (list, tuple)):
6280
return "[" + ", ".join(raw_json_dumps(entry) for entry in value) + "]"
6381
elif isinstance(value, dict) and all(isinstance(key, str) for key in value):
6482
return "{" + ", ".join("%s: %s" % (json.dumps(key), raw_json_dumps(val))

lmfdb/api/test_api.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import json
22
from decimal import Decimal
33

4+
from psycodict.encoding import numeric_converter
5+
6+
from lmfdb.api.api import raw_json_dumps
47
from lmfdb.tests import LmfdbTest
58

69
class ApiTest(LmfdbTest):
@@ -61,6 +64,27 @@ def test_api_examples_json(self):
6164
data = self.tc.get("/api/{}".format(query), follow_redirects=True).get_data(as_text=True)
6265
assert '"label": "12.2.167630295667.1",' in data
6366

67+
def test_raw_json_dumps(self):
68+
r"""
69+
Check the raw serializer (LMFDB#1010) on the decimals that are awkward
70+
to look for in the database: one that is exactly zero, which psycodict
71+
hands back as an exact integer wrapper rather than as a real number,
72+
and which still carries the scale, and the sign, that Postgres sent.
73+
The exact strings are what matters, since numeric equality catches
74+
neither a lost scale nor a lost sign.
75+
"""
76+
for text in ('0.000', '-0.000', '1.250', '-0.30800984111840306468901426146'):
77+
out = raw_json_dumps(numeric_converter(text))
78+
assert out == text
79+
json.loads(out) # and every one of them is valid JSON
80+
# zeros nested in an array or an object keep their literal too
81+
out = raw_json_dumps([numeric_converter('1.250'), numeric_converter('0.000')])
82+
assert out == '[1.250, 0.000]'
83+
assert json.loads(out, parse_float=Decimal) == [Decimal('1.250'), Decimal('0.000')]
84+
out = raw_json_dumps({'a': numeric_converter('-0.000')})
85+
assert out == '{"a": -0.000}'
86+
assert json.loads(out, parse_float=Decimal) == {'a': Decimal('-0.000')}
87+
6488
def test_api_raw(self):
6589
r"""
6690
Check the raw output format (LMFDB#1010): newline-delimited JSON with

0 commit comments

Comments
 (0)