`, just as JSON knows a single
+{III}// numeric type, and hence has to lie in the safe integer range.
+{III}if !ourcommon.IsSafeInteger(casted) {{
+{IIII}err = xmlcommon.NewSerializationError(
+{IIIII}ourcommon.NotJsonAbleIntegerMessage(casted),
+{IIII})
+{IIII}return
+{III}}}
+{III}err = xmlcommon.WriteElementInNoNamespace(
+{IIII}encoder, "double", undeclareNamespace,
+{IIII}float64(casted),
+{IIII}xmlcommon.WriteAsText_double,
+{III})
+{III}return
+
{II}case float64:
{III}// NOTE (mristin):
{III}// JSON knows neither an infinity nor a not-a-number, so neither is
@@ -613,8 +637,8 @@ def _generate_writers() -> List[Stripped]:
{II}default:
{III}err = xmlcommon.NewSerializationError(
{IIII}fmt.Sprintf(
-{IIIII}"Expected a JSON-able value (a bool, a float64, a string, "+
-{IIIII}"a JsonArray or a JsonObject), but got: %T",
+{IIIII}"Expected a JSON-able value (a bool, a float64, an int, an int64, "+
+{IIIII}"a string, a JsonArray or a JsonObject), but got: %T",
{IIIII}value,
{IIII}),
{III})
@@ -767,6 +791,8 @@ def generate(repo_url: Stripped) -> str:
"""
ourtypes_url_literal = golang_common.string_literal(f"{repo_url}/types")
+ ourcommon_url_literal = golang_common.string_literal(f"{repo_url}/common")
+
xmlcommon_url_literal = golang_common.string_literal(
f"{repo_url}/internal/xmlcommon"
)
@@ -798,6 +824,7 @@ def generate(repo_url: Stripped) -> str:
{I}"regexp"
{I}"sort"
{I}"strconv"
+{I}ourcommon {ourcommon_url_literal}
{I}xmlcommon {xmlcommon_url_literal}
{I}ourtypes {ourtypes_url_literal}
)"""
diff --git a/aas_core_codegen/golang/main.py b/aas_core_codegen/golang/main.py
index 407888906..bd599314c 100644
--- a/aas_core_codegen/golang/main.py
+++ b/aas_core_codegen/golang/main.py
@@ -325,13 +325,22 @@ def execute(context: run.Context, stdout: TextIO, stderr: TextIO) -> int:
# The ``xmlrpc`` package is only needed when the meta-model actually uses
# a JSON-able type (``JSONValue``, ``JSONArray`` or ``JSONObject[K]``) --
# unlike the other packages above, which are always generated regardless of
- # the model.
+ # the model. The same holds for the tests of the JSON-able values.
if intermediate_uses.json_types(context.symbol_table):
rel_paths_generators = list(rel_paths_generators) + [
(
base_rel_path / "xmlrpc/xmlrpc.go",
lambda: (golang_lib.generate_xml_rpc(repo_url=repo_url), None),
),
+ (
+ base_rel_path / "jsonization/test/json_value_test.go",
+ lambda: (
+ golang_tests.generate_jsonization_test_json_value_test(
+ repo_url=repo_url
+ ),
+ None,
+ ),
+ ),
]
# NOTE (mristin):
@@ -386,6 +395,22 @@ def execute(context: run.Context, stdout: TextIO, stderr: TextIO) -> int:
),
]
+ # NOTE (mristin):
+ # We test the safe integer range whenever the SDK checks it, see
+ # :py:func:`intermediate_uses.safe_integer_checks`.
+ if intermediate_uses.safe_integer_checks(context.symbol_table):
+ rel_paths_generators = list(rel_paths_generators) + [
+ (
+ base_rel_path / "common/safe_integer_test/safe_integer_test.go",
+ lambda: (
+ golang_tests.generate_safe_integer_test(
+ symbol_table=verified_ir_table, repo_url=repo_url
+ ),
+ None,
+ ),
+ ),
+ ]
+
for rel_path, generator_func in rel_paths_generators:
assert not rel_path.is_absolute()
diff --git a/aas_core_codegen/golang/tests/__init__.py b/aas_core_codegen/golang/tests/__init__.py
index 3ce2180c6..a6c7ec287 100644
--- a/aas_core_codegen/golang/tests/__init__.py
+++ b/aas_core_codegen/golang/tests/__init__.py
@@ -14,11 +14,13 @@
_generate_enhancing_test,
_generate_is_xxx_test,
_generate_parse_int_test,
+ _generate_safe_integer_test,
_generate_string_helpers_test,
_generate_jsonization_test_classes_with_descendants_test,
_generate_jsonization_test_common_test,
_generate_jsonization_test_concrete_classes_test,
_generate_jsonization_test_enums_test,
+ _generate_jsonization_test_json_value_test,
_generate_verification_test,
_generate_xmlization_test_concrete_classes_test,
_generate_xmlization_test_common_test,
@@ -35,6 +37,7 @@
generate_ourtesting_tracing = _generate_ourtesting_tracing.generate
generate_arithmetic_test = _generate_arithmetic_test.generate
generate_parse_int_test = _generate_parse_int_test.generate
+generate_safe_integer_test = _generate_safe_integer_test.generate
generate_descend_test_common = _generate_descend_test_common.generate
generate_descend_test_descend_once_test = (
_generate_descend_test_descend_once_test.generate
@@ -51,6 +54,9 @@
_generate_jsonization_test_concrete_classes_test.generate
)
generate_jsonization_test_enums_test = _generate_jsonization_test_enums_test.generate
+generate_jsonization_test_json_value_test = (
+ _generate_jsonization_test_json_value_test.generate
+)
generate_verification_test = _generate_verification_test.generate
generate_xmlization_test_concrete_classes_test = (
_generate_xmlization_test_concrete_classes_test.generate
diff --git a/aas_core_codegen/golang/tests/_generate_jsonization_test_concrete_classes_test.py b/aas_core_codegen/golang/tests/_generate_jsonization_test_concrete_classes_test.py
index ca35783d5..dc426ec9c 100644
--- a/aas_core_codegen/golang/tests/_generate_jsonization_test_concrete_classes_test.py
+++ b/aas_core_codegen/golang/tests/_generate_jsonization_test_concrete_classes_test.py
@@ -1,7 +1,7 @@
"""Generate code to test the JSON de/serialization of concrete classes."""
import io
-from typing import List
+from typing import Final, List, Sequence, Tuple
from icontract import ensure
@@ -159,10 +159,52 @@ def _generate_for_class(cls: intermediate.ConcreteClass) -> List[Stripped]:
def _generate_serialization_failure_infrastructure() -> List[Stripped]:
- """Generate the helpers shared by the tests of the serialization failures."""
+ """Generate the helpers shared by the tests of the non-finite numbers."""
return [
Stripped(
f"""\
+// Assert that `that` can not be serialized to JSON, and that the failure
+// is reported at `expectedPath`.
+func assertSerializationFailsAt(
+{I}t *testing.T,
+{I}that ourtypes.IClass,
+{I}expectedPath string,
+) {{
+{I}_, err := ourjsonization.ToJsonable(that)
+
+{I}if err == nil {{
+{II}t.Fatalf(
+{III}"Expected the serialization to fail at %s, but it succeeded",
+{III}expectedPath,
+{II})
+{II}return
+{I}}}
+
+{I}seriaErr, ok := err.(*ourjsonization.SerializationError)
+{I}if !ok {{
+{II}t.Fatalf(
+{III}"Expected a *SerializationError, but got %T: %v",
+{III}err, err,
+{II})
+{II}return
+{I}}}
+
+{I}if seriaErr.PathString() != expectedPath {{
+{II}t.Fatalf(
+{III}"Expected the serialization to fail at %s, "+
+{IIII}"but it failed at %s: %s",
+{III}expectedPath, seriaErr.PathString(), seriaErr.Message,
+{II})
+{I}}}
+}}"""
+ ),
+ ]
+
+
+def _generate_first_expected_path() -> Stripped:
+ """Generate the helper locating the first recorded example of a class."""
+ return Stripped(
+ f"""\
// Determine the path to the first recorded example of `modelType`.
func mustFirstExpectedPath(t *testing.T, modelType string) string {{
{I}pths := ourtesting.FindFilesBySuffixRecursively(
@@ -185,134 +227,353 @@ def _generate_serialization_failure_infrastructure() -> List[Stripped]:
{I}return pths[0]
}}"""
- ),
+ )
+
+
+def _generate_safe_integer_infrastructure() -> List[Stripped]:
+ """
+ Generate the helpers shared by the tests of the safe integer range.
+
+ The verification, the JSON serialization and the XML serialization have to
+ agree on which integers they refuse, so that a verified instance is
+ guaranteed to serialize.
+ """
+ return [
Stripped(
f"""\
-// Assert that `that` can not be serialized to JSON, and that the failure
-// is reported at `expectedPath`.
-func assertSerializationFailsAt(
+// Assert that the verification, the JSON serialization and the XML
+// serialization of `that` all report `expectedMessage`. The verification
+// reports it at `expectedVerificationPath`, and the JSON serialization at
+// `expectedSerializationPath`.
+//
+// The serialization names a property by its getter, *e.g.*, `Value()`, while
+// the verification names it by the property, *e.g.*, `Value`.
+func assertSafeIntegerRefused(
{I}t *testing.T,
{I}that ourtypes.IClass,
-{I}expectedPath string,
+{I}expectedVerificationPath string,
+{I}expectedSerializationPath string,
+{I}expectedMessage string,
) {{
-{I}_, err := ourjsonization.ToJsonable(that)
+{I}var veriErrs []*ourverification.VerificationError
+{I}ourverification.Verify(
+{II}that,
+{II}func(err *ourverification.VerificationError) (abort bool) {{
+{III}veriErrs = append(veriErrs, err)
+{III}return
+{II}}},
+{I})
-{I}if err == nil {{
+{I}found := false
+{I}for _, veriErr := range veriErrs {{
+{II}if veriErr.PathString() == expectedVerificationPath &&
+{III}veriErr.Message == expectedMessage {{
+{III}found = true
+{III}break
+{II}}}
+{I}}}
+{I}if !found {{
{II}t.Fatalf(
-{III}"Expected the serialization to fail at %s, but it succeeded",
-{III}expectedPath,
+{III}"Expected the verification to report at %s: %s, but got: %v",
+{III}expectedVerificationPath, expectedMessage, veriErrs,
{II})
{II}return
{I}}}
-{I}seriaErr, ok := err.(*ourjsonization.SerializationError)
-{I}if !ok {{
+{I}_, err := ourjsonization.ToJsonable(that)
+{I}jsonErr, ok := err.(*ourjsonization.SerializationError)
+{I}if !ok ||
+{II}jsonErr.PathString() != expectedSerializationPath ||
+{II}jsonErr.Message != expectedMessage {{
{II}t.Fatalf(
-{III}"Expected a *SerializationError, but got %T: %v",
-{III}err, err,
+{III}"Expected the JSON serialization to fail at %s: %s, but got: %v",
+{III}expectedSerializationPath, expectedMessage, err,
{II})
{II}return
{I}}}
-{I}if seriaErr.PathString() != expectedPath {{
+{I}var builder strings.Builder
+{I}err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true)
+{I}xmlErr, ok := err.(*ourxmlization.SerializationError)
+{I}if !ok || xmlErr.Message != expectedMessage {{
{II}t.Fatalf(
-{III}"Expected the serialization to fail at %s, "+
-{IIII}"but it failed at %s: %s",
-{III}expectedPath, seriaErr.PathString(), seriaErr.Message,
+{III}"Expected the XML serialization to fail with: %s, but got: %v",
+{III}expectedMessage, err,
{II})
+{II}return
+{I}}}
+}}"""
+ ),
+ Stripped(
+ f"""\
+// Assert that neither the verification, nor the JSON serialization nor
+// the XML serialization of `that` refuses an integer as unsafe.
+//
+// The verification might still report the violations of the invariants, as
+// the tests change the instance at will.
+func assertSafeIntegerAccepted(
+{I}t *testing.T,
+{I}that ourtypes.IClass,
+) {{
+{I}ourverification.Verify(
+{II}that,
+{II}func(err *ourverification.VerificationError) (abort bool) {{
+{III}if strings.Contains(err.Message, "safe integer range") {{
+{IIII}t.Fatalf("Expected no safe-integer error, but got: %v", err)
+{III}}}
+{III}return
+{II}}},
+{I})
+
+{I}_, err := ourjsonization.ToJsonable(that)
+{I}if err != nil {{
+{II}t.Fatalf("Expected no JSON serialization error, but got: %v", err)
+{II}return
+{I}}}
+
+{I}var builder strings.Builder
+{I}err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true)
+{I}if err != nil {{
+{II}t.Fatalf("Expected no XML serialization error, but got: %v", err)
+{II}return
{I}}}
}}"""
),
]
-def _generate_serialization_failure_test(
- numeric_place: intermediate.NumericPlace,
+def _generate_test_on_first_expected(
+ test_name: Identifier,
+ cls: intermediate.ConcreteClass,
+ value_type: str,
+ values: Sequence[str],
+ mutation: str,
+ assertion: str,
) -> Stripped:
- """Generate the test that a number unrepresentable in JSON is refused."""
- if numeric_place.a_type is intermediate.PrimitiveType.FLOAT:
- value_type = "float64"
- zero_literal = "0"
- what = "non_finite"
- values_joined = "math.Inf(1),\nmath.Inf(-1),\nmath.NaN()"
- else:
- value_type = "int64"
- zero_literal = "0"
- what = "out_of_range"
- values_joined = "9007199254740992,\n-9007199254740992"
+ """
+ Generate the test which changes the first recorded example of ``cls``.
+
+ For each of the ``values``, the ``mutation`` sets it to the de-serialized
+ ``instance``, and the ``assertion`` checks the outcome.
+ """
+ model_type_literal = golang_common.string_literal(naming.json_model_type(cls.name))
+
+ deserialization_function = golang_naming.function_name(
+ Identifier(f"{cls.name}_from_jsonable")
+ )
+
+ values_joined = ",\n".join(values)
+
+ return Stripped(
+ f"""\
+func {test_name}(t *testing.T) {{
+{I}pth := mustFirstExpectedPath(t, {model_type_literal})
+
+{I}for _, value := range []{value_type}{{
+{II}{indent_but_first_line(values_joined, II)},
+{I}}} {{
+{II}jsonable := ourtesting.MustReadJsonable(pth)
+
+{II}instance, deseriaErr := ourjsonization.{deserialization_function}(
+{III}jsonable,
+{II})
+{II}if !assertNoDeserializationError(t, deseriaErr, pth) {{
+{III}return
+{II}}}
+
+{II}{indent_but_first_line(mutation, II)}
+
+{II}{indent_but_first_line(assertion, II)}
+{I}}}
+}}"""
+ )
+
+
+#: Integers just outside the safe integer range
+_UNSAFE_INTEGERS: Final[Sequence[str]] = ["9007199254740992", "-9007199254740992"]
+
+#: Integers at the bounds of the safe integer range
+_SAFE_INTEGERS: Final[Sequence[str]] = ["9007199254740991", "-9007199254740991"]
+
+
+def _generate_numeric_place_tests(
+ numeric_place: intermediate.NumericPlace,
+) -> List[Stripped]:
+ """
+ Generate the tests that a number unrepresentable in JSON is refused.
+
+ A non-finite float is refused by the JSON serialization. An integer outside
+ the safe integer range is refused by the verification, the JSON and
+ the XML serialization alike, while the integers at the bounds are accepted.
+ """
+ value_type = golang_common.PRIMITIVE_TYPE_MAP[numeric_place.a_type]
getter_name = golang_naming.getter_name(numeric_place.prop.name)
setter_name = golang_naming.setter_name(numeric_place.prop.name)
if numeric_place.index is None:
- mutation = Stripped(f"instance.{setter_name}(value)")
- expected_path = f"{getter_name}()"
+ mutation = f"instance.{setter_name}(value)"
+ subscript = ""
elif numeric_place.in_list:
# NOTE (mristin):
# The value goes to the position indicated by the numeric place so that
# a serializer which always reports the index 0 does not pass.
items_joined = ", ".join(
- "value" if i == numeric_place.index else zero_literal
+ "value" if i == numeric_place.index else "0"
for i in range(numeric_place.index + 1)
)
- mutation = Stripped(f"instance.{setter_name}([]{value_type}{{{items_joined}}})")
- expected_path = f"{getter_name}()[{numeric_place.index}]"
+ mutation = f"instance.{setter_name}([]{value_type}{{{items_joined}}})"
+ subscript = f"[{numeric_place.index}]"
else:
# NOTE (mristin):
# A tuple is a value in Go, so it has to be read out, changed and
# written back.
- mutation = Stripped(
- f"""\
+ mutation = f"""\
tuple := instance.{getter_name}()
tuple.Item{numeric_place.index + 1} = value
instance.{setter_name}(tuple)"""
- )
- expected_path = f"{getter_name}()[{numeric_place.index}]"
+ subscript = f"[{numeric_place.index}]"
- model_type_literal = golang_common.string_literal(
- naming.json_model_type(numeric_place.cls.name)
+ # NOTE (mristin):
+ # The serialization names a property by its getter, while the verification
+ # names it by the property.
+ serialization_path_literal = golang_common.string_literal(
+ f"{getter_name}(){subscript}"
)
-
- deserialization_function = golang_naming.function_name(
- Identifier(f"{numeric_place.cls.name}_from_jsonable")
+ verification_path_literal = golang_common.string_literal(
+ f"{golang_naming.property_name(numeric_place.prop.name)}{subscript}"
)
- test_name = golang_naming.function_name(
- Identifier(
- f"Test_{numeric_place.cls.name}_serialization_fail_"
- f"on_{what}_{numeric_place.prop.name}"
- )
- )
+ cls = numeric_place.cls
+ prop_name = numeric_place.prop.name
- return Stripped(
- f"""\
-func {test_name}(t *testing.T) {{
-{I}pth := mustFirstExpectedPath(t, {model_type_literal})
+ if numeric_place.a_type is intermediate.PrimitiveType.FLOAT:
+ return [
+ _generate_test_on_first_expected(
+ test_name=golang_naming.function_name(
+ Identifier(
+ f"Test_{cls.name}_serialization_fail_on_non_finite_{prop_name}"
+ )
+ ),
+ cls=cls,
+ value_type=value_type,
+ values=["math.Inf(1)", "math.Inf(-1)", "math.NaN()"],
+ mutation=mutation,
+ assertion=f"""\
+assertSerializationFailsAt(
+{I}t,
+{I}instance,
+{I}{serialization_path_literal},
+)""",
+ )
+ ]
-{I}for _, value := range []{value_type}{{
-{II}{indent_but_first_line(values_joined, II)},
-{I}}} {{
-{II}jsonable := ourtesting.MustReadJsonable(pth)
+ return [
+ _generate_test_on_first_expected(
+ test_name=golang_naming.function_name(
+ Identifier(f"Test_{cls.name}_refuses_unsafe_integer_in_{prop_name}")
+ ),
+ cls=cls,
+ value_type=value_type,
+ values=_UNSAFE_INTEGERS,
+ mutation=mutation,
+ assertion=f"""\
+assertSafeIntegerRefused(
+{I}t,
+{I}instance,
+{I}{verification_path_literal},
+{I}{serialization_path_literal},
+{I}fmt.Sprintf(
+{II}"Expected an integer, but got %d, "+
+{II}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]",
+{II}value,
+{I}),
+)""",
+ ),
+ _generate_test_on_first_expected(
+ test_name=golang_naming.function_name(
+ Identifier(f"Test_{cls.name}_accepts_safe_integer_in_{prop_name}")
+ ),
+ cls=cls,
+ value_type=value_type,
+ values=_SAFE_INTEGERS,
+ mutation=mutation,
+ assertion="assertSafeIntegerAccepted(t, instance)",
+ ),
+ ]
-{II}instance, deseriaErr := ourjsonization.{deserialization_function}(
-{III}jsonable,
-{II})
-{II}if !assertNoDeserializationError(t, deseriaErr, pth) {{
-{III}return
-{II}}}
-{II}{indent_but_first_line(mutation, II)}
+def _json_value_properties(
+ symbol_table: intermediate.SymbolTable,
+) -> List[Tuple[intermediate.ConcreteClass, intermediate.Property]]:
+ """List the required properties which are bare JSON-able values."""
+ return [
+ (cls, prop)
+ for cls in symbol_table.concrete_classes
+ for prop in cls.properties
+ if isinstance(prop.type_annotation, intermediate.JsonValueTypeAnnotation)
+ ]
-{II}assertSerializationFailsAt(
-{III}t,
-{III}instance,
-{III}{golang_common.string_literal(expected_path)},
-{II})
-{I}}}
-}}"""
+
+def _generate_json_value_tests(
+ cls: intermediate.ConcreteClass, prop: intermediate.Property
+) -> List[Stripped]:
+ """
+ Generate the tests of an integer nested in a JSON-able value.
+
+ The integer can only be put there programmatically, as JSON itself gives
+ a float64. Outside the safe integer range, it is refused by
+ the verification, the JSON and the XML serialization alike, while
+ the integers at the bounds are accepted.
+ """
+ setter_name = golang_naming.setter_name(prop.name)
+
+ # NOTE (mristin):
+ # We nest the integer in an array so that the path is checked at depth.
+ mutation = f"instance.{setter_name}(ourtypes.JsonArray{{value}})"
+
+ serialization_path_literal = golang_common.string_literal(
+ f"{golang_naming.getter_name(prop.name)}()[0]"
+ )
+ verification_path_literal = golang_common.string_literal(
+ f"{golang_naming.property_name(prop.name)}[0]"
)
+ return [
+ _generate_test_on_first_expected(
+ test_name=golang_naming.function_name(
+ Identifier(f"Test_{cls.name}_refuses_unsafe_integer_in_{prop.name}")
+ ),
+ cls=cls,
+ value_type="int64",
+ values=_UNSAFE_INTEGERS,
+ mutation=mutation,
+ assertion=f"""\
+assertSafeIntegerRefused(
+{I}t,
+{I}instance,
+{I}{verification_path_literal},
+{I}{serialization_path_literal},
+{I}fmt.Sprintf(
+{II}"Expected a JSON-able value, but got the integer %d, "+
+{II}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]",
+{II}value,
+{I}),
+)""",
+ ),
+ _generate_test_on_first_expected(
+ test_name=golang_naming.function_name(
+ Identifier(f"Test_{cls.name}_accepts_safe_integer_in_{prop.name}")
+ ),
+ cls=cls,
+ value_type="int64",
+ values=_SAFE_INTEGERS,
+ mutation=mutation,
+ assertion="assertSafeIntegerAccepted(t, instance)",
+ ),
+ ]
+
#: Stand in for the import block, which is filled in at the very end: it
#: depends on what the generated code actually names, and an unused import does
@@ -330,20 +591,37 @@ def generate(symbol_table: intermediate.SymbolTable, repo_url: Stripped) -> str:
"""Generate code to test the JSON de/serialization of concrete classes."""
numeric_places = intermediate.numeric_places(symbol_table)
+ json_value_properties = _json_value_properties(symbol_table)
+
blocks: List[Stripped] = [
Stripped("package jsonization_test"),
golang_common.WARNING,
_IMPORT_PLACEHOLDER,
]
- if len(numeric_places) > 0:
+ if len(numeric_places) > 0 or len(json_value_properties) > 0:
+ blocks.append(_generate_first_expected_path())
+
+ if any(
+ numeric_place.a_type is intermediate.PrimitiveType.FLOAT
+ for numeric_place in numeric_places
+ ):
blocks.extend(_generate_serialization_failure_infrastructure())
+ if len(json_value_properties) > 0 or any(
+ numeric_place.a_type is intermediate.PrimitiveType.INT
+ for numeric_place in numeric_places
+ ):
+ blocks.extend(_generate_safe_integer_infrastructure())
+
for concrete_cls in symbol_table.concrete_classes:
blocks.extend(_generate_for_class(cls=concrete_cls))
for numeric_place in numeric_places:
- blocks.append(_generate_serialization_failure_test(numeric_place))
+ blocks.extend(_generate_numeric_place_tests(numeric_place))
+
+ for cls, prop in json_value_properties:
+ blocks.extend(_generate_json_value_tests(cls, prop))
blocks.append(golang_common.WARNING)
@@ -351,14 +629,18 @@ def generate(symbol_table: intermediate.SymbolTable, repo_url: Stripped) -> str:
import_lines: List[str] = []
for module, literal in (
+ ("xml", f'{I}"encoding/xml"'),
("fmt", f'{I}"fmt"'),
("filepath", f'{I}"path/filepath"'),
("math", f'{I}"math"'),
("sort", f'{I}"sort"'),
+ ("strings", f'{I}"strings"'),
("testing", f'{I}"testing"'),
("ourjsonization", f'{I}ourjsonization "{repo_url}/jsonization"'),
("ourtesting", f'{I}ourtesting "{repo_url}/ourtesting"'),
("ourtypes", f'{I}ourtypes "{repo_url}/types"'),
+ ("ourverification", f'{I}ourverification "{repo_url}/verification"'),
+ ("ourxmlization", f'{I}ourxmlization "{repo_url}/xmlization"'),
):
if golang_common.names_package(blocks, module):
import_lines.append(literal)
diff --git a/aas_core_codegen/golang/tests/_generate_jsonization_test_json_value_test.py b/aas_core_codegen/golang/tests/_generate_jsonization_test_json_value_test.py
new file mode 100644
index 000000000..861990547
--- /dev/null
+++ b/aas_core_codegen/golang/tests/_generate_jsonization_test_json_value_test.py
@@ -0,0 +1,183 @@
+"""Generate code to test the JSON de/serialization of the JSON-able values."""
+
+from icontract import ensure
+
+from aas_core_codegen.common import Stripped
+from aas_core_codegen.golang import common as golang_common
+from aas_core_codegen.golang.common import (
+ INDENT as I,
+ INDENT2 as II,
+ INDENT3 as III,
+ INDENT4 as IIII,
+ INDENT5 as IIIII,
+)
+
+
+# fmt: off
+@ensure(
+ lambda result: result.endswith('\n'),
+ "Trailing newline mandatory for valid end-of-files"
+)
+# fmt: on
+def generate(repo_url: Stripped) -> str:
+ """
+ Generate code to test the JSON de/serialization of the JSON-able values.
+
+ The integers are put together programmatically, as JSON itself only gives
+ a float64. They are accepted only within the safe integer range
+ [-2^53 + 1, 2^53 - 1].
+ """
+ blocks = [
+ Stripped("package jsonization_test"),
+ golang_common.WARNING,
+ Stripped(
+ f"""\
+import (
+{I}"fmt"
+{I}"testing"
+{I}ourjsonization "{repo_url}/jsonization"
+)"""
+ ),
+ Stripped(
+ f"""\
+// Format the error of an integer outside the safe integer range.
+func unsafeIntegerMessage(value int64) string {{
+{I}return fmt.Sprintf(
+{II}"Expected a JSON-able value, but got the integer %d, "+
+{II}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]",
+{II}value,
+{I})
+}}"""
+ ),
+ Stripped(
+ f"""\
+func TestJsonValueFromJsonableAcceptsSafeIntegers(t *testing.T) {{
+{I}for _, value := range []int64{{
+{II}9007199254740991,
+{II}-9007199254740991,
+{I}}} {{
+{II}result, err := ourjsonization.JsonValueFromJsonable(value)
+{II}if err != nil {{
+{III}t.Fatalf(
+{IIII}"Expected no error when parsing %d, but got: %s",
+{IIII}value, err.Error(),
+{III})
+{III}return
+{II}}}
+
+{II}if result != float64(value) {{
+{III}t.Fatalf(
+{IIII}"Expected %d to be parsed as a float64, but got %T: %v",
+{IIII}value, result, result,
+{III})
+{III}return
+{II}}}
+{I}}}
+}}"""
+ ),
+ Stripped(
+ f"""\
+func TestJsonValueFromJsonableRefusesUnsafeIntegers(t *testing.T) {{
+{I}for _, value := range []int64{{
+{II}9007199254740992,
+{II}-9007199254740992,
+{I}}} {{
+{II}_, err := ourjsonization.JsonValueFromJsonable(value)
+{II}if err == nil {{
+{III}t.Fatalf("Expected an error when parsing %d, but got none", value)
+{III}return
+{II}}}
+
+{II}deseriaErr, ok := err.(*ourjsonization.DeserializationError)
+{II}if !ok {{
+{III}t.Fatalf(
+{IIII}"Expected a *DeserializationError, but got %T: %v",
+{IIII}err, err,
+{III})
+{III}return
+{II}}}
+
+{II}expected := unsafeIntegerMessage(value)
+{II}if deseriaErr.Message != expected {{
+{III}t.Fatalf(
+{IIII}"Expected the error %q, but got %q",
+{IIII}expected, deseriaErr.Message,
+{III})
+{III}return
+{II}}}
+{I}}}
+}}"""
+ ),
+ Stripped(
+ f"""\
+func TestJsonValueToJsonableAcceptsSafeIntegers(t *testing.T) {{
+{I}for _, value := range []int64{{
+{II}9007199254740991,
+{II}-9007199254740991,
+{I}}} {{
+{II}result, err := ourjsonization.JsonValueToJsonable(value)
+{II}if err != nil {{
+{III}t.Fatalf(
+{IIII}"Expected no error when serializing %d, but got: %s",
+{IIII}value, err.Error(),
+{III})
+{III}return
+{II}}}
+
+{II}if result != float64(value) {{
+{III}t.Fatalf(
+{IIII}"Expected %d to be serialized as a float64, but got %T: %v",
+{IIII}value, result, result,
+{III})
+{III}return
+{II}}}
+{I}}}
+}}"""
+ ),
+ Stripped(
+ f"""\
+func TestJsonValueToJsonableRefusesUnsafeIntegers(t *testing.T) {{
+{I}for _, value := range []int64{{
+{II}9007199254740992,
+{II}-9007199254740992,
+{I}}} {{
+{II}_, err := ourjsonization.JsonValueToJsonable(
+{III}[]interface{{}}{{
+{IIII}map[string]interface{{}}{{
+{IIIII}"key": value,
+{IIII}}},
+{III}}},
+{II})
+{II}if err == nil {{
+{III}t.Fatalf("Expected an error when serializing %d, but got none", value)
+{III}return
+{II}}}
+
+{II}seriaErr, ok := err.(*ourjsonization.SerializationError)
+{II}if !ok {{
+{III}t.Fatalf(
+{IIII}"Expected a *SerializationError, but got %T: %v",
+{IIII}err, err,
+{III})
+{III}return
+{II}}}
+
+{II}expected := unsafeIntegerMessage(value)
+{II}if seriaErr.Message != expected {{
+{III}t.Fatalf(
+{IIII}"Expected the error %q, but got %q",
+{IIII}expected, seriaErr.Message,
+{III})
+{III}return
+{II}}}
+{I}}}
+}}"""
+ ),
+ golang_common.WARNING,
+ ]
+
+ return "\n\n".join(blocks) + "\n"
+
+
+assert generate.__doc__ is not None
+assert generate.__doc__.strip().startswith(__doc__.strip())
diff --git a/aas_core_codegen/golang/tests/_generate_safe_integer_test.py b/aas_core_codegen/golang/tests/_generate_safe_integer_test.py
new file mode 100644
index 000000000..5e6cb4d30
--- /dev/null
+++ b/aas_core_codegen/golang/tests/_generate_safe_integer_test.py
@@ -0,0 +1,107 @@
+"""Generate code to test the predicates of the safe integer range."""
+
+from typing import List
+
+from icontract import ensure, require
+
+from aas_core_codegen import intermediate
+from aas_core_codegen.common import Stripped, indent_but_first_line
+from aas_core_codegen.golang import common as golang_common
+from aas_core_codegen.golang.common import (
+ INDENT as I,
+ INDENT2 as II,
+ INDENT3 as III,
+)
+from aas_core_codegen.intermediate import uses as intermediate_uses
+
+
+def _generate_test(
+ function_name: str, value_type: str, safe: str, unsafe: str
+) -> Stripped:
+ """
+ Generate the test of the predicate ``function_name`` over ``value_type``.
+
+ The ``safe`` and the ``unsafe`` are the values, joined over lines, which
+ the predicate has to accept and to refuse, respectively.
+ """
+ return Stripped(
+ f"""\
+func Test{function_name}(t *testing.T) {{
+{I}for _, value := range []{value_type}{{
+{II}{indent_but_first_line(safe, II)},
+{I}}} {{
+{II}if !ourcommon.{function_name}(value) {{
+{III}t.Errorf("Expected %v to be a safe integer, but it is not", value)
+{II}}}
+{I}}}
+
+{I}for _, value := range []{value_type}{{
+{II}{indent_but_first_line(unsafe, II)},
+{I}}} {{
+{II}if ourcommon.{function_name}(value) {{
+{III}t.Errorf("Expected %v not to be a safe integer, but it is", value)
+{II}}}
+{I}}}
+}}"""
+ )
+
+
+# fmt: off
+@require(
+ lambda symbol_table:
+ intermediate_uses.safe_integer_checks(symbol_table)
+)
+@ensure(
+ lambda result: result.endswith('\n'),
+ "Trailing newline mandatory for valid end-of-files"
+)
+# fmt: on
+def generate(symbol_table: intermediate.SymbolTable, repo_url: Stripped) -> str:
+ """
+ Generate code to test the predicates of the safe integer range.
+
+ The bounds of the range [-2^53 + 1, 2^53 - 1] are accepted, and the integers
+ just outside are refused.
+ """
+ blocks: List[Stripped] = [
+ Stripped("package common_safe_integer_test"),
+ golang_common.WARNING,
+ Stripped(
+ f"""\
+import (
+{I}"math"
+{I}"testing"
+{I}ourcommon "{repo_url}/common"
+)"""
+ ),
+ _generate_test(
+ function_name="IsSafeInteger",
+ value_type="int64",
+ safe="0,\n9007199254740991,\n-9007199254740991",
+ unsafe=(
+ "9007199254740992,\n-9007199254740992,\n"
+ "math.MaxInt64,\nmath.MinInt64"
+ ),
+ ),
+ ]
+
+ # NOTE (mristin):
+ # The predicate over a float64 serves only the de-serialization of
+ # the integer properties, so it is generated only for them.
+ if intermediate_uses.integers(symbol_table):
+ blocks.append(
+ _generate_test(
+ function_name="IsSafeIntegerFloat64",
+ value_type="float64",
+ safe="0,\n9007199254740991,\n-9007199254740991",
+ unsafe="9007199254740992,\n-9007199254740992,\n1e300,\n-1e300",
+ )
+ )
+
+ blocks.append(golang_common.WARNING)
+
+ return "\n\n".join(blocks) + "\n"
+
+
+assert generate.__doc__ is not None
+assert generate.__doc__.strip().startswith(__doc__.strip())
diff --git a/aas_core_codegen/golang/tests/_generate_xmlization_test_concrete_classes_test.py b/aas_core_codegen/golang/tests/_generate_xmlization_test_concrete_classes_test.py
index d44b2b765..5c5edac9f 100644
--- a/aas_core_codegen/golang/tests/_generate_xmlization_test_concrete_classes_test.py
+++ b/aas_core_codegen/golang/tests/_generate_xmlization_test_concrete_classes_test.py
@@ -1,7 +1,7 @@
"""Generate code to test the XML de/serialization of concrete classes."""
import io
-from typing import Dict, List, Mapping, Tuple
+from typing import Dict, Final, List, Mapping, Sequence, Tuple
from icontract import ensure
@@ -212,6 +212,13 @@ def _generate_for_cls(cls: intermediate.ConcreteClass) -> List[Stripped]:
("S G k =", "[]byte{72, 105}"),
("", "[]byte{}"),
],
+ intermediate.PrimitiveType.INT: [
+ # NOTE:
+ # The bounds of the safe integer range [-2^53 + 1, 2^53 - 1] are
+ # accepted, see ``_UNSAFE_INTEGER_TEXTS`` for the ones just outside.
+ ("9007199254740991", "int64(9007199254740991)"),
+ ("-9007199254740991", "int64(-9007199254740991)"),
+ ],
intermediate.PrimitiveType.BOOL: [
# NOTE:
# ``xs:boolean`` spells the two values in four ways, not two.
@@ -223,6 +230,14 @@ def _generate_for_cls(cls: intermediate.ConcreteClass) -> List[Stripped]:
}
+#: Texts of the valid ``xs:long``'s which lie just outside the safe integer range,
+#: and are hence refused
+_UNSAFE_INTEGER_TEXTS: Final[Sequence[str]] = [
+ "9007199254740992",
+ "-9007199254740992",
+]
+
+
def _lexical_test_name_chunk(text: str) -> str:
"""
Make a chunk of a test name out of ``text``.
@@ -258,6 +273,7 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
relevant = [
(a_type, prop_by_a_type[a_type])
for a_type in (
+ intermediate.PrimitiveType.INT,
intermediate.PrimitiveType.FLOAT,
intermediate.PrimitiveType.BOOL,
intermediate.PrimitiveType.BYTEARRAY,
@@ -272,19 +288,22 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
cls_name_go = golang_naming.private_function_name(
Identifier(f"read_{cls.name}_with")
)
+ patch_function = golang_naming.private_function_name(
+ Identifier(f"patch_{cls.name}_with")
+ )
cls_name_xml = naming.xml_class_name(cls.name)
unmarshal_function = golang_naming.function_name(Identifier("unmarshal"))
result: List[Stripped] = [
Stripped(
f"""\
-// Read the first recorded example of {interface_name} with the content of
-// the element `xmlName` replaced by `text`.
-func {cls_name_go}(
+// Give the text of the first recorded example of {interface_name} with
+// the content of the element `xmlName` replaced by `text`.
+func {patch_function}(
{I}t *testing.T,
{I}xmlName string,
{I}text string,
-) ourtypes.{interface_name} {{
+) string {{
{I}pths := ourtesting.FindFilesBySuffixRecursively(
{II}filepath.Join(
{III}ourtesting.TestDataDir,
@@ -313,7 +332,19 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
{I}start := strings.Index(original, "<"+xmlName+">") + len(xmlName) + 2
{I}end := strings.Index(original, ""+xmlName+">")
-{I}patched := original[:start] + text + original[end:]
+{I}return original[:start] + text + original[end:]
+}}"""
+ ),
+ Stripped(
+ f"""\
+// Read the first recorded example of {interface_name} with the content of
+// the element `xmlName` replaced by `text`.
+func {cls_name_go}(
+{I}t *testing.T,
+{I}xmlName string,
+{I}text string,
+) ourtypes.{interface_name} {{
+{I}patched := {patch_function}(t, xmlName, text)
{I}decoder := xml.NewDecoder(strings.NewReader(patched))
{I}deserialized, deseriaErr := ourxmlization.{unmarshal_function}(decoder)
@@ -331,7 +362,7 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
{I}return instance
}}"""
- )
+ ),
]
for a_type, prop in relevant:
@@ -369,6 +400,44 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
{I}if {comparison} {{
{II}t.Fatalf("Expected %v, but got %v", expected, got)
{I}}}
+}}"""
+ )
+ )
+
+ if a_type is not intermediate.PrimitiveType.INT:
+ continue
+
+ for a_text in _UNSAFE_INTEGER_TEXTS:
+ test_name = golang_naming.function_name(
+ Identifier(
+ f"Test_{prop.name}_read_from_"
+ f"{_lexical_test_name_chunk(a_text)}_fails"
+ )
+ )
+
+ expected_message_literal = golang_common.string_literal(
+ f"Expected an integer, but got {a_text}, which is outside "
+ f"the safe integer range [-2^53 + 1, 2^53 - 1]"
+ )
+
+ result.append(
+ Stripped(
+ f"""\
+func {test_name}(t *testing.T) {{
+{I}patched := {patch_function}(
+{II}t,
+{II}{golang_common.string_literal(prop_xml_name)},
+{II}{golang_common.string_literal(a_text)},
+{I})
+
+{I}decoder := xml.NewDecoder(strings.NewReader(patched))
+{I}_, err := ourxmlization.{unmarshal_function}(decoder)
+
+{I}expected := {expected_message_literal}
+{I}deseriaErr, ok := err.(*ourxmlization.DeserializationError)
+{I}if !ok || deseriaErr.Message != expected {{
+{II}t.Fatalf("Expected the de-serialization error %q, but got: %v", expected, err)
+{I}}}
}}"""
)
)
diff --git a/aas_core_codegen/intermediate/uses.py b/aas_core_codegen/intermediate/uses.py
index bdd9ebb35..0f1370175 100644
--- a/aas_core_codegen/intermediate/uses.py
+++ b/aas_core_codegen/intermediate/uses.py
@@ -45,6 +45,30 @@ def json_types(symbol_table: _types.SymbolTable) -> bool:
return False
+def integers(symbol_table: _types.SymbolTable) -> bool:
+ """
+ Check whether an instance of the model can hold an integer.
+
+ This is the case if a property holds an integer at any depth of its type
+ annotation (*e.g.*, ``List[int]``), or if a constrained primitive constrains
+ an integer. Every such integer has to lie in the safe integer range
+ [-2^53 + 1, 2^53 - 1], so that it can be written to JSON, and the SDKs check
+ it on de/serialization and on verification.
+ """
+ return any(
+ constrained_primitive.constrainee is _types.PrimitiveType.INT
+ for constrained_primitive in symbol_table.constrained_primitives
+ ) or any(
+ isinstance(type_anno, _types.PrimitiveTypeAnnotation)
+ and type_anno.a_type is _types.PrimitiveType.INT
+ for cls in symbol_table.classes
+ for prop in cls.properties
+ for type_anno in _types.over_type_annotation_and_nested_type_annotations(
+ prop.type_annotation
+ )
+ )
+
+
def _over_transpilable_nodes(
symbol_table: _types.SymbolTable,
) -> Iterator[parse_tree.Node]:
@@ -80,6 +104,32 @@ def _over_transpilable_nodes(
yield from parse_tree.over_nodes(node)
+def int_call(symbol_table: _types.SymbolTable) -> bool:
+ """
+ Check whether the meta-model calls the built-in ``int`` in transpilable code.
+
+ The generators use this function to decide whether they need to generate
+ the helper functions and the tests for parsing the integers.
+ """
+ return any(
+ isinstance(node, parse_tree.FunctionCall) and node.name.identifier == "int"
+ for node in _over_transpilable_nodes(symbol_table)
+ )
+
+
+def safe_integer_checks(symbol_table: _types.SymbolTable) -> bool:
+ """
+ Check whether the SDK needs to check the safe integer range [-2^53 + 1, 2^53 - 1].
+
+ The range applies to the integers of the model (see :py:func:`integers`),
+ the integers in JSON-able values (see :py:func:`json_types`) and the integers
+ parsed by the transpiled built-in ``int`` (see :py:func:`int_call`).
+ The generators use this function to decide whether they need to generate
+ the predicate which checks the range.
+ """
+ return integers(symbol_table) or json_types(symbol_table) or int_call(symbol_table)
+
+
def modulo(symbol_table: _types.SymbolTable) -> bool:
"""
Check whether the meta-model uses the modulo operator in transpilable code.
@@ -347,19 +397,6 @@ def string_operations(
)
-def int_call(symbol_table: _types.SymbolTable) -> bool:
- """
- Check whether the meta-model calls the built-in ``int`` in transpilable code.
-
- The generators use this function to decide whether they need to generate
- the helper functions and the tests for parsing the integers.
- """
- return any(
- isinstance(node, parse_tree.FunctionCall) and node.name.identifier == "int"
- for node in _over_transpilable_nodes(symbol_table)
- )
-
-
def _nested_sets(
type_annotation: _types.TypeAnnotationUnion,
) -> Iterator[_types.SetTypeAnnotation]:
diff --git a/aas_core_codegen/java/lib/_generate_common.py b/aas_core_codegen/java/lib/_generate_common.py
index 6a7005e19..34d95ffc9 100644
--- a/aas_core_codegen/java/lib/_generate_common.py
+++ b/aas_core_codegen/java/lib/_generate_common.py
@@ -106,7 +106,11 @@
}}"""
)
-#: Parse a text as a safe integer; this is how we transpile the built-in ``int``
+#: Parse a text as a safe integer; this is how we transpile the built-in ``int``.
+#:
+#: We limit ourselves to the safe integers, though a ``long`` could hold larger
+#: ones, as TypeScript represents the integers as ``number``, and the invariants
+#: must behave the same in all the SDKs.
_PARSE_SAFE_INT = Stripped(
f"""\
/**
@@ -116,8 +120,12 @@
* transpilation. We accept only an optional sign followed by the ASCII digits,
* and only the safe integers, i.e., the integers within
* {{@code -(2^53 - 1)}} and {{@code 2^53 - 1}}, which a double-precision
- * floating-point number represents exactly. This way, all the SDKs behave
- * the same.
+ * floating-point number represents exactly.
+ *
+ * We check the safe range even though a {{@code long}} could hold larger
+ * integers. TypeScript represents the integers as {{@code number}}, which holds
+ * them exactly only within the safe range, and all the SDKs check the same
+ * range so that the invariants which parse a text behave the same everywhere.
*
*
We do not use {{@link Long#parseLong(String)}} as it accepts the digits
* of any script, and the range of all the 64-bit integers.
@@ -127,9 +135,6 @@
* @throws IllegalArgumentException if {{@code text}} is not a safe integer
*/
public static long parseSafeInt(String text) {{
-{I}// This is 2^53 - 1.
-{I}final long maxSafeInteger = 9007199254740991L;
-
{I}final int length = text.length();
{I}int offset = 0;
@@ -155,9 +160,10 @@
{II}}}
{II}// NOTE (mristin):
-{II}// The value can not overflow as we check the range after each digit.
+{II}// The value is at most 2^53 - 1 before this step, so it can not
+{II}// overflow a long.
{II}value = value * 10 + (character - '0');
-{II}if (value > maxSafeInteger) {{
+{II}if (!Verification.isSafeInteger(value)) {{
{III}throw new IllegalArgumentException(
{IIII}"Expected a safe integer, but got a text out of its range: " + text);
{II}}}
@@ -428,10 +434,16 @@ def _generate_string_helpers(
blocks: List[Stripped] = [
java_common.WARNING,
Stripped(f"package {package}.common;"),
- code,
- java_common.WARNING,
]
+ if intermediate_uses.int_call(symbol_table):
+ # NOTE (mristin):
+ # The parsing checks the range with the same predicate as
+ # the de/serialization and the verification.
+ blocks.append(Stripped(f"import {package}.verification.Verification;"))
+
+ blocks.extend([code, java_common.WARNING])
+
return Stripped("\n\n".join(blocks))
diff --git a/aas_core_codegen/java/lib/_generate_jsonization.py b/aas_core_codegen/java/lib/_generate_jsonization.py
index 9ccb2e243..829a87b04 100644
--- a/aas_core_codegen/java/lib/_generate_jsonization.py
+++ b/aas_core_codegen/java/lib/_generate_jsonization.py
@@ -25,6 +25,7 @@
INDENT3 as III,
INDENT4 as IIII,
INDENT5 as IIIII,
+ INDENT6 as IIIIII,
)
#: Maximal length of a generated line before a call is broken over two lines
@@ -1225,9 +1226,74 @@ def _generate_from_method_for_named_union(
# endregion
-def _generate_primitive_parsers() -> List[Stripped]:
+def _generate_try_long_from() -> Stripped:
+ """
+ Generate the parser of a 64-bit integer.
+
+ The integer has to lie in the safe integer range, as checked by
+ ``Verification.isSafeInteger``, so that the parsed instance can be
+ written back to JSON.
+ """
+ return Stripped(
+ f"""\
+/** Convert {{@code value}} to a long 64-bit integer.
+ *
+ *
A whole number such as {{@code 5.0}} or {{@code 1e2}} is accepted as
+ * an integer, since JSON has a single number type. The integer has to lie in
+ * the safe integer range [-2^53 + 1, 2^53 - 1].
+ *
+ * @param node JSON node to be parsed
+ */
+private static Reporting.Result tryLongFrom(JsonNode value) {{
+{I}if (!value.isNumber()) {{
+{II}final Reporting.Error error = new Reporting.Error(
+{III}"Expected an integer, but got: " + value.getNodeType());
+{II}return Reporting.Result.failure(error);
+{I}}}
+
+{I}switch (value.numberType()) {{
+{II}case INT:
+{II}case LONG: {{
+{III}final long integer = value.longValue();
+{III}if (Verification.isSafeInteger(integer)) {{
+{IIII}return Reporting.Result.success(integer);
+{III}}}
+{III}break;
+{II}}}
+
+{II}case BIG_INTEGER: {{
+{III}final BigInteger integer = value.bigIntegerValue();
+{III}if (Verification.isSafeInteger(integer)) {{
+{IIII}return Reporting.Result.success(integer.longValue());
+{III}}}
+{III}break;
+{II}}}
+
+{II}default: {{
+{III}final double number = value.doubleValue();
+{III}if (!Double.isFinite(number) || Math.rint(number) != number) {{
+{IIII}return Reporting.Result.failure(new Reporting.Error(
+{IIIII}"Expected an integer, but got " + number +
+{IIIIII}", which is not a whole number"));
+{III}}}
+{III}if (Verification.isSafeInteger(number)) {{
+{IIII}return Reporting.Result.success((long) number);
+{III}}}
+{III}break;
+{II}}}
+{I}}}
+
+{I}return Reporting.Result.failure(new Reporting.Error(
+{II}Verification.integerOutsideSafeRangeMessage(value)));
+}}"""
+ )
+
+
+def _generate_primitive_parsers(
+ symbol_table: intermediate.SymbolTable,
+) -> List[Stripped]:
"""Generate the parsers of the primitive JSON values."""
- return [
+ result: List[Stripped] = [
Stripped(
f"""\
/** Convert {{@code value}} to a string.
@@ -1256,22 +1322,19 @@ def _generate_primitive_parsers() -> List[Stripped]:
{I}return Reporting.Result.success(value.asBoolean());
}}"""
),
- Stripped(
- f"""\
-/** Convert {{@code value}} to a long 64-bit integer.
- * @param node JSON node to be parsed
- */
-private static Reporting.Result tryLongFrom(JsonNode value) {{
-{I}if (!value.isIntegralNumber()) {{
-{II}final Reporting.Error error = new Reporting.Error(
-{III}"Expected a JsonValue of Long, but got " + value.getNodeType());
-{II}return Reporting.Result.failure(error);
-{I}}}
-{I}return Reporting.Result.success(value.asLong());
-}}"""
- ),
- Stripped(
- f"""\
+ ]
+
+ # NOTE (mristin):
+ # The parser of an integer relies on ``Verification.isSafeInteger`` and
+ # ``Verification.integerOutsideSafeRangeMessage``, the latter of which is
+ # only generated if an instance can hold an integer.
+ if intermediate_uses.integers(symbol_table):
+ result.append(_generate_try_long_from())
+
+ result.extend(
+ [
+ Stripped(
+ f"""\
/** Convert {{@code value}} to a double-precision 64-bit float.
* @param node JSON node to be parsed
*/
@@ -1300,9 +1363,9 @@ def _generate_primitive_parsers() -> List[Stripped]:
{I}return Reporting.Result.success(asDouble);
}}"""
- ),
- Stripped(
- f"""\
+ ),
+ Stripped(
+ f"""\
private static Reporting.Result tryBytesFrom(JsonNode value) {{
{I}if (!value.isTextual()) {{
{II}final Reporting.Error error = new Reporting.Error(
@@ -1323,8 +1386,11 @@ def _generate_primitive_parsers() -> List[Stripped]:
{I}return Reporting.Result.success(decodedData);
}}"""
- ),
- ]
+ ),
+ ]
+ )
+
+ return result
def _generate_json_able_helpers(
@@ -1350,21 +1416,21 @@ def _generate_json_able_helpers(
* A JSON-able value is, recursively, exactly as JSON itself is defined:
* a null node (JSON null), a boolean, a number, a string, an array of
* JSON-able values or an object of JSON-able values with string keys.
- * An infinity and a not-a-number are rejected at any depth, as JSON can not
- * represent them at all.
+ * An infinity, a not-a-number and an integer outside the safe integer
+ * range [-2^53 + 1, 2^53 - 1] are rejected at any depth.
*
*
The result is a deep copy, and never {{@code node}} itself, so that
* the parsed instance does not share a tree with the document it came from.
*
+ *
The path of an error is relative to {{@code node}}.
+ *
* @param node to be parsed
- * @param path JSON path to {{@code node}}, written into the messages
* @return the parsed value, or an error
*/
-private static Reporting.Result tryJsonValueFromAt(
-{II}JsonNode node, String path) {{
+private static Reporting.Result tryJsonValueFrom(JsonNode node) {{
{I}if (node == null || node.isMissingNode()) {{
{II}return Reporting.Result.failure(new Reporting.Error(
-{III}"Expected a JSON-able value at " + path + ", but got: " + node));
+{III}"Expected a JSON-able value, but got: " + node));
{I}}}
{I}if (node.isNull()) {{
@@ -1372,15 +1438,9 @@ def _generate_json_able_helpers(
{I}}}
{I}if (node.isNumber()) {{
-{II}// NOTE (mristin):
-{II}// JSON knows neither an infinity nor a not-a-number. Jackson's own
-{II}// parser never gives us one, but {{@code node}} may well have been
-{II}// put together programmatically.
-{II}final double value = node.doubleValue();
-{II}if (Double.isInfinite(value) || Double.isNaN(value)) {{
-{III}return Reporting.Result.failure(new Reporting.Error(
-{IIII}"Expected a JSON-able value at " + path + ", but got the number " +
-{IIII}value + ", which is neither finite nor representable in JSON"));
+{II}final String cause = Verification.checkJsonAbleNumber(node);
+{II}if (cause != null) {{
+{III}return Reporting.Result.failure(new Reporting.Error(cause));
{II}}}
{II}return Reporting.Result.success(node.deepCopy());
{I}}}
@@ -1393,9 +1453,10 @@ def _generate_json_able_helpers(
{II}final ArrayNode result = JsonNodeFactory.instance.arrayNode();
{II}int index = 0;
{II}for (JsonNode item : node) {{
-{III}final Reporting.Result itemResult = tryJsonValueFromAt(
-{IIII}item, path + "[" + index + "]");
+{III}final Reporting.Result itemResult = tryJsonValueFrom(item);
{III}if (itemResult.isError()) {{
+{IIII}itemResult.getError().prependSegment(
+{IIIII}new Reporting.IndexSegment(index));
{IIII}return itemResult;
{III}}}
{III}result.add(itemResult.getResult());
@@ -1409,9 +1470,11 @@ def _generate_json_able_helpers(
{II}final Iterator names = node.fieldNames();
{II}while (names.hasNext()) {{
{III}final String name = names.next();
-{III}final Reporting.Result valueResult = tryJsonValueFromAt(
-{IIII}node.get(name), path + "." + name);
+{III}final Reporting.Result valueResult = tryJsonValueFrom(
+{IIII}node.get(name));
{III}if (valueResult.isError()) {{
+{IIII}valueResult.getError().prependSegment(
+{IIIII}new Reporting.KeySegment(name));
{IIII}return valueResult;
{III}}}
{III}result.set(name, valueResult.getResult());
@@ -1421,20 +1484,7 @@ def _generate_json_able_helpers(
{I}return Reporting.Result.failure(new Reporting.Error(
{II}"Expected a JSON-able value (a null, a boolean, a number, a string, " +
-{II}"an array or an object) at " + path + ", but got: " +
-{II}node.getNodeType()));
-}}"""
- ),
- Stripped(
- f"""\
-/**
- * Parse {{@code node}} as a JSON-able value.
- *
- * @param node to be parsed
- * @return the parsed value, or an error
- */
-private static Reporting.Result tryJsonValueFrom(JsonNode node) {{
-{I}return tryJsonValueFromAt(node, "$");
+{II}"an array or an object), but got: " + node.getNodeType()));
}}"""
),
Stripped(
@@ -1494,9 +1544,9 @@ def _generate_json_able_serializer(
* Serialize {{@code that}} as a JSON node.
*
* A JSON-able value is almost a JSON node as it comes, but not quite: its
- * type rules out neither a missing node nor a non-finite number, neither of
- * which JSON can represent. Each is refused here, at any depth. A null node
- * is written as a JSON null.
+ * type rules out neither a missing node, a non-finite number nor an integer
+ * outside the safe integer range [-2^53 + 1, 2^53 - 1]. Each is refused
+ * here, at any depth. A null node is written as a JSON null.
*
*
The result is a deep copy, and never {{@code that}} itself, so that
* the serialized document does not share a tree with the instance it came
@@ -1517,12 +1567,9 @@ def _generate_json_able_serializer(
{I}}}
{I}if (that.isNumber()) {{
-{II}final double value = that.doubleValue();
-{II}if (Double.isInfinite(value) || Double.isNaN(value)) {{
-{III}throw new _SerializeFailure(
-{IIII}new Reporting.Error(
-{IIIII}"Expected a JSON-able value, but got the number " + value +
-{IIIII}", which is neither finite nor representable in JSON"));
+{II}final String cause = Verification.checkJsonAbleNumber(that);
+{II}if (cause != null) {{
+{III}throw new _SerializeFailure(new Reporting.Error(cause));
{II}}}
{II}return that.deepCopy();
{I}}}
@@ -1650,7 +1697,7 @@ def _generate_deserialize_impl(
# endregion
- blocks: List[Stripped] = _generate_primitive_parsers()
+ blocks: List[Stripped] = _generate_primitive_parsers(symbol_table)
if needs_prepend_name:
blocks.append(_generate_prepend_name())
@@ -1794,11 +1841,61 @@ def _generate_deserialize_from(name: str) -> Stripped:
return Stripped(writer.getvalue())
+def _generate_deserialize_json_able(name: str, result_type: str) -> Stripped:
+ """
+ Generate the facade deserialization method for the JSON-able ``name``.
+
+ The ``name`` is ``JsonValue``, ``JsonArray`` or ``JsonObject``, and
+ the method gives back a node of ``result_type``.
+ """
+ what = name[len("Json") :].lower()
+
+ return Stripped(
+ f"""\
+/**
+ * Deserialize a JSON-able {what} from {{@code node}}.
+ *
+ *
A JSON-able value is, recursively, exactly as JSON itself is defined:
+ * a null node (JSON null), a boolean, a number, a string, an array of
+ * JSON-able values or an object of JSON-able values with string keys.
+ * An infinity, a not-a-number and an integer outside the safe integer
+ * range [-2^53 + 1, 2^53 - 1] are rejected at any depth.
+ *
+ *
The result is a deep copy, and never {{@code node}} itself.
+ *
+ * @param node JSON node to be parsed
+ * @throws DeserializeException if {{@code node}} is not a JSON-able {what}
+ */
+public static {result_type} deserialize{name}(JsonNode node) {{
+{I}final Reporting.Result<{result_type}> result =
+{II}_DeserializeImplementation.try{name}From(
+{III}node);
+
+{I}return result.onError(error -> {{
+{II}throw new DeserializeException(
+{III}Reporting.generateJsonPath(error.getPathSegments()),
+{III}error.getCause());
+{I}}});
+}}"""
+ )
+
+
def _generate_deserialize(
symbol_table: intermediate.SymbolTable,
) -> Stripped:
"""Generate the deserializer with a deserialization method for each class."""
blocks: List[Stripped] = []
+
+ if intermediate_uses.json_types(symbol_table):
+ blocks.append(
+ _generate_deserialize_json_able(name="JsonValue", result_type="JsonNode")
+ )
+ blocks.append(
+ _generate_deserialize_json_able(name="JsonArray", result_type="ArrayNode")
+ )
+ blocks.append(
+ _generate_deserialize_json_able(name="JsonObject", result_type="ObjectNode")
+ )
for our_type in symbol_table.our_types:
if isinstance(our_type, intermediate.Enumeration):
blocks.append(
@@ -2695,18 +2792,13 @@ def _generate_long_to_json_node_helper() -> Stripped:
* @param that value to be converted
*/
private static JsonNode longToJsonNode(Long that) {{
-{I}// NOTE (mristin):
-{I}// Outside this range an integer can not be exactly represented as a 64-bit
-{I}// floating-point number, which is what the JSON de-serializers of the other
-{I}// languages read a number into.
{I}final long primitiveThat = that.longValue();
-{I}if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) {{
+{I}if (!Verification.isSafeInteger(primitiveThat)) {{
{II}throw new _SerializeFailure(
{III}new Reporting.Error(
-{IIII}"The integer can not be serialized to JSON as it is outside "
-{IIIII}+ "the range [-2^53 + 1, 2^53 - 1]: " + that));
+{IIII}Verification.integerOutsideSafeRangeMessage(primitiveThat)));
{I}}}
-{I}return JsonNodeFactory.instance.numberNode(that);
+{I}return JsonNodeFactory.instance.numberNode(primitiveThat);
}}"""
)
@@ -2877,6 +2969,33 @@ def _generate_serialize(
),
]
+ if intermediate_uses.json_types(symbol_table):
+ blocks.append(
+ Stripped(
+ f"""\
+/**
+ * Serialize the JSON-able value {{@code that}} into a JSON node.
+ *
+ *
The result is a deep copy, and never {{@code that}} itself. A null node
+ * is written as a JSON null.
+ *
+ * @throws SerializeException if {{@code that}} contains, at any depth,
+ * a missing node, an infinity, a not-a-number or an integer outside
+ * the safe integer range [-2^53 + 1, 2^53 - 1]
+ */
+public static JsonNode jsonValueToJsonNode(JsonNode that) {{
+{I}try {{
+{II}return _Transformer.jsonValueToJsonNode(that);
+{I}}} catch (_SerializeFailure failure) {{
+{II}final Reporting.Error error = failure.getError();
+{II}throw new SerializeException(
+{III}Reporting.generateJavaPath(error.getPathSegments()),
+{III}error.getCause());
+{I}}}
+}}"""
+ )
+ )
+
if len(symbol_table.enumerations) > 0:
# NOTE (mristin):
# A literal carries its own text, so a single non-generic method
@@ -3032,6 +3151,19 @@ def generate(
[
Stripped("import com.fasterxml.jackson.databind.node.JsonNodeFactory;"),
Stripped("import com.fasterxml.jackson.databind.node.ObjectNode;"),
+ ]
+ )
+
+ if intermediate_uses.json_types(symbol_table) or intermediate_uses.integers(
+ symbol_table
+ ):
+ imports.append(Stripped(f"import {package}.verification.Verification;"))
+
+ if intermediate_uses.integers(symbol_table):
+ imports.append(Stripped("import java.math.BigInteger;"))
+
+ imports.extend(
+ [
Stripped("import java.util.ArrayList;"),
Stripped("import java.util.Base64;"),
Stripped("import java.util.HashSet;"),
diff --git a/aas_core_codegen/java/lib/_generate_verification.py b/aas_core_codegen/java/lib/_generate_verification.py
index 21312cd35..433273028 100644
--- a/aas_core_codegen/java/lib/_generate_verification.py
+++ b/aas_core_codegen/java/lib/_generate_verification.py
@@ -44,6 +44,7 @@
INDENT4 as IIII,
INDENT5 as IIIII,
INDENT6 as IIIIII,
+ INDENT7 as IIIIIII,
)
from aas_core_codegen.parse import tree as parse_tree, retree as parse_retree
@@ -822,8 +823,9 @@ def _needs_verification(type_annotation: intermediate.TypeAnnotationUnion) -> bo
"""
Check whether a value of ``type_annotation`` has anything to verify at any depth.
- In Java, we verify all our types, including the enumerations, and all
- the JSON-able values.
+ In Java, we verify all our types, including the enumerations, all
+ the JSON-able values, and all the integers, which have to lie in the safe
+ integer range to be serializable to JSON.
"""
return any(
isinstance(
@@ -835,6 +837,10 @@ def _needs_verification(type_annotation: intermediate.TypeAnnotationUnion) -> bo
intermediate.JsonObjectTypeAnnotation,
),
)
+ or (
+ isinstance(type_anno, intermediate.PrimitiveTypeAnnotation)
+ and type_anno.a_type is intermediate.PrimitiveType.INT
+ )
for type_anno in intermediate.over_type_annotation_and_nested_type_annotations(
type_annotation
)
@@ -899,12 +905,16 @@ def _verify_method(type_anno: intermediate.TypeAnnotationUnion) -> Identifier:
"""
Name the method of ``Verification`` verifying a value of ``type_anno``.
- The names of the methods generated for the containers and for the JSON
- objects with constrained keys contain an underscore, so they never collide
- with the methods of the enumerations and of the constrained primitives,
- which are named by ``capitalized_camel_case``.
+ The names of the methods generated for the containers, for the JSON
+ objects with constrained keys and for the integers contain an underscore,
+ so they never collide with the methods of the enumerations and of
+ the constrained primitives, which are named by ``capitalized_camel_case``.
"""
- if isinstance(type_anno, intermediate.OurTypeAnnotation):
+ if isinstance(type_anno, intermediate.PrimitiveTypeAnnotation):
+ if type_anno.a_type is intermediate.PrimitiveType.INT:
+ return Identifier("verify_long")
+
+ elif isinstance(type_anno, intermediate.OurTypeAnnotation):
our_type = type_anno.our_type
if isinstance(our_type, intermediate.Enumeration):
@@ -1066,14 +1076,10 @@ def _generate_verify_json_object_with_constrained_key(
{II}Verification.streamOfFieldNames(that)
{III}.flatMap(key ->
{IIII}Verification.{key_verify_method}(key)
-{IIIII}.map(error ->
-{IIIIII}// NOTE (mristin):
-{IIIIII}// A member of an open JSON object is no property of one of
-{IIIIII}// our classes, so it gets no segment of its own -- the key
-{IIIIII}// goes into the message instead, as it does in
-{IIIIII}// ``verifyJsonValue``.
-{IIIIII}new Reporting.Error(
-{IIIIII}{I}"In the member \\"" + key + "\\": " + error.getCause()))));
+{IIIII}.map(error -> {{
+{IIIIII}error.prependSegment(new Reporting.KeySegment(key));
+{IIIIII}return error;
+{IIIII}}})));
}}"""
)
@@ -1210,6 +1216,182 @@ def _generate_transform_for_class(
return Stripped(writer.getvalue()), None
+def _generate_is_safe_integer() -> List[Stripped]:
+ """
+ Generate the predicates whether a number lies in the safe integer range.
+
+ The predicates are pure logic, and carry no message, so that every range
+ check -- of the integer properties and of the JSON-able numbers alike --
+ goes through them, while each caller words its own error.
+ """
+ return [
+ Stripped(
+ f"""\
+/**
+ * Check whether the integer {{@code value}} lies in the safe integer range
+ * [-2^53 + 1, 2^53 - 1].
+ *
+ *
JSON knows a single numeric type, which all the other SDKs read into
+ * a {{@code double}}. Outside this range, an integer can not be exactly
+ * represented as a {{@code double}}, so a reader might quietly get
+ * a different number than the one which was written.
+ *
+ * @param value integer to be checked
+ * @return true if {{@code value}} is a safe integer
+ */
+public static boolean isSafeInteger(long value) {{
+{I}return -9007199254740991L <= value && value <= 9007199254740991L;
+}}"""
+ ),
+ Stripped(
+ f"""\
+/**
+ * Check whether the whole number {{@code value}} lies in the safe integer
+ * range [-2^53 + 1, 2^53 - 1].
+ *
+ *
Only the range is checked here; the caller has to make sure that
+ * {{@code value}} is a whole number in the first place.
+ *
+ * @param value whole number to be checked
+ * @return true if {{@code value}} is a safe integer
+ */
+public static boolean isSafeInteger(double value) {{
+{I}return -9007199254740991.0 <= value && value <= 9007199254740991.0;
+}}"""
+ ),
+ Stripped(
+ f"""\
+/**
+ * Check whether the big integer {{@code value}} lies in the safe integer
+ * range [-2^53 + 1, 2^53 - 1].
+ *
+ * @param value integer to be checked
+ * @return true if {{@code value}} is a safe integer
+ */
+public static boolean isSafeInteger(BigInteger value) {{
+{I}// NOTE (mristin):
+{I}// A bit length below 64 means that the value fits a long, so we can
+{I}// narrow it without truncating it silently.
+{I}return value.bitLength() < 64 && isSafeInteger(value.longValue());
+}}"""
+ ),
+ ]
+
+
+def _generate_safe_integer_verification() -> List[Stripped]:
+ """
+ Generate the message and the verification of the integers out of range.
+
+ The de-serialization and the serialization of the integers use the same
+ message, so that a verified instance is always serializable to JSON, and
+ the errors agree when it is not.
+ """
+ return [
+ Stripped(
+ f"""\
+/**
+ * Explain that the integer {{@code value}} lies outside the safe integer
+ * range [-2^53 + 1, 2^53 - 1].
+ *
+ * @param value integer, written as it was given
+ * @return the message
+ */
+public static String integerOutsideSafeRangeMessage(Object value) {{
+{I}return "Expected an integer, but got " + value +
+{II}", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]";
+}}"""
+ ),
+ Stripped(
+ f"""\
+/**
+ * Verify that the integer {{@code that}} lies in the safe integer range.
+ */
+private static Stream verify_long(long that) {{
+{I}return isSafeInteger(that)
+{II}? Stream.empty()
+{II}: Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that)));
+}}"""
+ ),
+ ]
+
+
+def _generate_check_json_able_number() -> Stripped:
+ """
+ Generate the check of a number in a JSON-able value.
+
+ The verification, the de-serialization and the serialization of a JSON-able
+ value all use it, so that they agree on what a JSON-able number is.
+ The XML-RPC de-serialization reads an integer before it has a node, so
+ the message is given separately as well.
+ """
+ return Stripped(
+ f"""\
+/**
+ * Explain that the integer {{@code value}} of a JSON-able value lies outside
+ * the safe integer range [-2^53 + 1, 2^53 - 1].
+ *
+ * @param value integer, written as it was given
+ * @return the message
+ */
+public static String jsonAbleIntegerOutsideSafeRangeMessage(Object value) {{
+{I}return "Expected a JSON-able value, but got the integer " + value +
+{II}", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]";
+}}
+
+/**
+ * Check that the number {{@code node}} is a number of a JSON-able value.
+ *
+ * JSON knows a single numeric type, which all the other SDKs read into
+ * a {{@code double}}. Hence a JSON-able number has to be finite, and
+ * an integer has to lie in the safe integer range [-2^53 + 1, 2^53 - 1],
+ * as for the integer properties of the model -- otherwise a reader might
+ * quietly get a different number than the one which was given.
+ *
+ *
The verification, the de-serialization and the serialization of
+ * a JSON-able value all use this check, so that they agree on what
+ * a JSON-able number is.
+ *
+ * @param node number node to be checked
+ * @return cause why {{@code node}} is not JSON-able, or null if it is
+ */
+public static String checkJsonAbleNumber(JsonNode node) {{
+{I}switch (node.numberType()) {{
+{II}case INT:
+{II}case LONG:
+{III}if (isSafeInteger(node.longValue())) {{
+{IIII}return null;
+{III}}}
+{III}break;
+
+{II}case BIG_INTEGER:
+{III}// NOTE (mristin):
+{III}// Jackson gives a big integer for every integer if
+{III}// ``USE_BIG_INTEGER_FOR_INTS`` is enabled, so a small one can arrive
+{III}// as well.
+{III}if (isSafeInteger(node.bigIntegerValue())) {{
+{IIII}return null;
+{III}}}
+{III}break;
+
+{II}default: {{
+{III}// NOTE (mristin):
+{III}// JSON knows neither an infinity nor a not-a-number. Jackson's own
+{III}// parser never gives us one, but {{@code node}} may well have been
+{III}// put together programmatically.
+{III}final double value = node.doubleValue();
+{III}if (Double.isInfinite(value) || Double.isNaN(value)) {{
+{IIII}return "Expected a JSON-able value, but got the number " + value +
+{IIIII}", which is neither finite nor representable in JSON";
+{III}}}
+{III}return null;
+{II}}}
+{I}}}
+
+{I}return jsonAbleIntegerOutsideSafeRangeMessage(node);
+}}"""
+ )
+
+
def _generate_verify_json_value(
symbol_table: intermediate.SymbolTable,
) -> List[Stripped]:
@@ -1217,14 +1399,12 @@ def _generate_verify_json_value(
Generate the methods verifying that a value is JSON-able.
A JSON-able value is, recursively, exactly as JSON itself is defined, but
- a Jackson node rules out neither of the two ways of not being one:
- a missing node and a non-finite number. Both are reported here, at any
+ a Jackson node rules out neither a missing node nor a number which is not
+ JSON-able (see ``checkJsonAbleNumber``). Both are reported here, at any
depth. A null node is a JSON null, and hence a JSON-able value.
- The path *within* a JSON-able value goes into the message rather than into
- the structured path of the error: a name segment points at a property of
- one of our classes and an index segment at an item of one of our lists,
- and a member of an open JSON object is neither.
+ The path within a JSON-able value is given by index and key segments, as
+ in the de-serialization.
"""
if not intermediate_uses.json_types(symbol_table):
return []
@@ -1243,79 +1423,60 @@ def _generate_verify_json_value(
{I}return names.stream();
}}"""
),
+ _generate_check_json_able_number(),
Stripped(
f"""\
/**
* Verify that {{@code node}} is a JSON-able value, at any depth.
*
+ *
The path of an error is relative to {{@code node}}.
+ *
* @param node to be verified
- * @param path JSON path to {{@code node}}, written into the messages
* @return a stream of errors, if any
*/
-private static Stream verifyJsonValueAt(
-{II}JsonNode node, String path) {{
+public static Stream verifyJsonValue(JsonNode node) {{
{I}if (node == null || node.isMissingNode()) {{
{II}return Stream.of(
{III}new Reporting.Error(
-{IIII}"Expected a JSON-able value at " + path
-{IIIII}+ ", but got: " + node));
+{IIII}"Expected a JSON-able value, but got: " + node));
{I}}}
-{I}if (node.isNull()) {{
+{I}if (node.isNull() || node.isBoolean() || node.isTextual()) {{
{II}return Stream.empty();
{I}}}
{I}if (node.isNumber()) {{
-{II}// NOTE (mristin):
-{II}// JSON knows neither an infinity nor a not-a-number, so neither is
-{II}// a JSON-able value, even though a Jackson node holds either.
-{II}final double value = node.doubleValue();
-{II}if (Double.isInfinite(value) || Double.isNaN(value)) {{
-{III}return Stream.of(
-{IIII}new Reporting.Error(
-{IIIII}"Expected a JSON-able value at " + path
-{IIIIII}+ ", but got the number " + value
-{IIIIII}+ ", which is neither finite nor representable in JSON"));
-{II}}}
-{II}return Stream.empty();
-{I}}}
-
-{I}if (node.isBoolean() || node.isTextual()) {{
-{II}return Stream.empty();
+{II}final String cause = checkJsonAbleNumber(node);
+{II}return (cause == null)
+{III}? Stream.empty()
+{III}: Stream.of(new Reporting.Error(cause));
{I}}}
{I}if (node.isArray()) {{
{II}return Verification.zip(
{III}IntStream.iterate(0, i -> i + 1).boxed(),
{III}StreamSupport.stream(node.spliterator(), false))
-{IIII}.flatMap(elemTuple -> verifyJsonValueAt(
-{IIIII}elemTuple.getSecond(),
-{IIIII}path + "[" + elemTuple.getFirst() + "]"));
+{IIII}.flatMap(itemTuple -> verifyJsonValue(itemTuple.getSecond())
+{IIIII}.map(error -> {{
+{IIIIII}error.prependSegment(
+{IIIIIII}new Reporting.IndexSegment(itemTuple.getFirst()));
+{IIIIII}return error;
+{IIIII}}}));
{I}}}
{I}if (node.isObject()) {{
{II}return streamOfFieldNames(node)
-{III}.flatMap(name -> verifyJsonValueAt(
-{IIII}node.get(name), path + "." + name));
+{III}.flatMap(name -> verifyJsonValue(node.get(name))
+{IIII}.map(error -> {{
+{IIIII}error.prependSegment(new Reporting.KeySegment(name));
+{IIIII}return error;
+{IIII}}}));
{I}}}
{I}return Stream.of(
{II}new Reporting.Error(
{III}"Expected a JSON-able value (a null, a boolean, a number, a string, "
-{IIII}+ "an array or an object) at " + path
-{IIII}+ ", but got: " + node.getNodeType()));
-}}"""
- ),
- Stripped(
- f"""\
-/**
- * Verify that {{@code node}} is a JSON-able value, at any depth.
- *
- * @param node to be verified
- * @return a stream of errors, if any
- */
-public static Stream verifyJsonValue(JsonNode node) {{
-{I}return verifyJsonValueAt(node, "$");
+{IIII}+ "an array or an object), but got: " + node.getNodeType()));
}}"""
),
Stripped(
@@ -1487,6 +1648,15 @@ def _generate_verify_constrained_primitive(
that_type = java_common.PRIMITIVE_TYPE_MAP[constrained_primitive.constrainee]
+ # NOTE (mristin):
+ # An integer has to lie in the safe integer range to be serializable to
+ # JSON, whatever the invariants of the constrained primitive say.
+ initial_stream = (
+ "verify_long(that)"
+ if constrained_primitive.constrainee is intermediate.PrimitiveType.INT
+ else "Stream.empty()"
+ )
+
writer = io.StringIO()
writer.write(
f"""\
@@ -1495,7 +1665,7 @@ def _generate_verify_constrained_primitive(
*/
public static Stream verify{name} (
{I}{that_type} that) {{
-{I}Stream errorStream = Stream.empty();
+{I}Stream errorStream = {initial_stream};
"""
)
@@ -1704,6 +1874,12 @@ def generate(
)
)
+ if intermediate_uses.safe_integer_checks(symbol_table):
+ verification_blocks.extend(_generate_is_safe_integer())
+
+ if intermediate_uses.integers(symbol_table):
+ verification_blocks.extend(_generate_safe_integer_verification())
+
verification_blocks.extend(_generate_verify_json_value(symbol_table=symbol_table))
transformer_block, transformer_errors = _generate_transformer(
diff --git a/aas_core_codegen/java/lib/_generate_xml_rpc.py b/aas_core_codegen/java/lib/_generate_xml_rpc.py
index 3990c5b9e..898ebf960 100644
--- a/aas_core_codegen/java/lib/_generate_xml_rpc.py
+++ b/aas_core_codegen/java/lib/_generate_xml_rpc.py
@@ -209,17 +209,20 @@ def _generate_readers() -> List[Stripped]:
{I}// does a JSON document, so reading every double element as a double
{I}// node would silently turn the 1 of a document into a 1.0 on the way
{I}// through XML, and back again.
+{I}//
+{I}// Such an integer has to be a safe integer, exactly as in JSON. We read
+{I}// it as a big integer first, so that a numeral beyond a long is refused
+{I}// rather than truncated.
{I}if (text.indexOf('.') < 0
{II}&& text.indexOf('e') < 0
{II}&& text.indexOf('E') < 0) {{
-{II}try {{
-{III}return Reporting.Result.success(
-{IIII}JsonNodeFactory.instance.numberNode(Long.parseLong(text)));
-{II}}} catch (NumberFormatException exception) {{
-{III}// NOTE (mristin):
-{III}// The numeral does not fit a long, so it stays a double -- which
-{III}// is what a JSON parser would give for it as well.
+{II}final BigInteger integer = new BigInteger(text);
+{II}if (!Verification.isSafeInteger(integer)) {{
+{III}return Reporting.Result.failure(new Reporting.Error(
+{IIII}Verification.jsonAbleIntegerOutsideSafeRangeMessage(integer)));
{II}}}
+{II}return Reporting.Result.success(
+{III}JsonNodeFactory.instance.numberNode(integer.longValue()));
{I}}}
{I}return Reporting.Result.success(
@@ -508,15 +511,13 @@ def _generate_writers() -> List[Stripped]:
{I}}}
{I}if (that.isNumber()) {{
-{II}final double value = that.doubleValue();
-
{II}// NOTE (mristin):
-{II}// JSON knows neither an infinity nor a not-a-number, so neither is
-{II}// a JSON-able value, and readXmlRpcDouble refuses to read either back.
-{II}if (Double.isInfinite(value) || Double.isNaN(value)) {{
-{III}throw new XmlCommon.SerializeFailure(new Reporting.Error(
-{IIII}"Expected a JSON-able value, but got the number " + value +
-{IIII}", which is neither finite nor representable in JSON"));
+{II}// We check the number exactly as the verification and the JSON
+{II}// de/serialization do, and readXmlRpcDouble refuses to read back
+{II}// what we refuse here.
+{II}final String cause = Verification.checkJsonAbleNumber(that);
+{II}if (cause != null) {{
+{III}throw new XmlCommon.SerializeFailure(new Reporting.Error(cause));
{II}}}
{II}XmlCommon.writeElementInNoNamespace(
@@ -719,6 +720,7 @@ def generate(package: java_common.PackageIdentifier) -> List[java_common.JavaFil
Stripped("import javax.xml.stream.XMLStreamConstants;"),
Stripped("import javax.xml.stream.XMLStreamException;"),
Stripped("import javax.xml.stream.XMLStreamWriter;"),
+ Stripped("import java.math.BigInteger;"),
Stripped("import java.util.Iterator;"),
Stripped("import java.util.regex.Pattern;"),
*(
@@ -727,6 +729,7 @@ def generate(package: java_common.PackageIdentifier) -> List[java_common.JavaFil
),
Stripped("import com.fasterxml.jackson.databind.node.JsonNodeFactory;"),
Stripped(f"import {package}.reporting.Reporting;"),
+ Stripped(f"import {package}.verification.Verification;"),
Stripped(f"import {package}.xmlcommon.XmlCommon;"),
]
diff --git a/aas_core_codegen/java/lib/_generate_xmlization.py b/aas_core_codegen/java/lib/_generate_xmlization.py
index add91c6ef..63915e48b 100644
--- a/aas_core_codegen/java/lib/_generate_xmlization.py
+++ b/aas_core_codegen/java/lib/_generate_xmlization.py
@@ -225,6 +225,13 @@ def _written_leaf_moniker(type_anno: intermediate.AtomicTypeAnnotation) -> str:
if primitive_type is intermediate.PrimitiveType.FLOAT:
return java_common.PRIMITIVE_TYPE_TO_MONIKER[intermediate.PrimitiveType.FLOAT]
+ # NOTE (mristin):
+ # An integer is written by ``writeLongContent``, which checks the safe
+ # integer range, so it can not share the moniker of the stringified leaves
+ # either.
+ if primitive_type is intermediate.PrimitiveType.INT:
+ return java_common.PRIMITIVE_TYPE_TO_MONIKER[intermediate.PrimitiveType.INT]
+
if primitive_type is not None:
return "stringified"
@@ -337,6 +344,9 @@ def _written_value_type(
if primitive_type is intermediate.PrimitiveType.FLOAT:
return Stripped("Double")
+ if primitive_type is intermediate.PrimitiveType.INT:
+ return Stripped("Long")
+
if primitive_type is not None:
return Stripped("Object")
@@ -396,6 +406,12 @@ def _content_writer_name(type_anno: intermediate.TypeAnnotationUnion) -> Identif
if primitive_type is intermediate.PrimitiveType.FLOAT:
return Identifier("writeDoubleContent")
+ # NOTE (mristin):
+ # An integer has to lie in the safe integer range, so that the instance
+ # can be written to JSON as well.
+ if primitive_type is intermediate.PrimitiveType.INT:
+ return Identifier("writeLongContent")
+
if primitive_type is not None:
return Identifier("writeStringifiedContent")
@@ -1487,11 +1503,31 @@ def _generate_content_reader(type_anno: intermediate.TypeAnnotationUnion) -> Str
arguments.append(Stripped(empty_value))
joined_arguments = ",\n".join(arguments)
- body = Stripped(
- f"""\
+
+ if primitive_type is intermediate.PrimitiveType.INT:
+ # NOTE (mristin):
+ # The text is parsed by the lexical rules of ``xs:long``, but
+ # the integer has to lie in the safe integer range as well, so
+ # that the instance can be written to JSON. We report it with
+ # the very message of the JSON de-serialization.
+ body = Stripped(
+ f"""\
+final Reporting.Result result = readText(
+{I}{indent_but_first_line(joined_arguments, I)});
+
+if (result.isError() || Verification.isSafeInteger(result.getResult())) {{
+{I}return result;
+}}
+
+return Reporting.Result.failure(new Reporting.Error(
+{I}Verification.integerOutsideSafeRangeMessage(result.getResult())));"""
+ )
+ else:
+ body = Stripped(
+ f"""\
return readText(
{I}{indent_but_first_line(joined_arguments, I)});"""
- )
+ )
else:
assert isinstance(type_anno, intermediate.OurTypeAnnotation) and isinstance(
type_anno.our_type, intermediate.Enumeration
@@ -2547,6 +2583,32 @@ def _generate_write_byte_array_content() -> Stripped:
)
+def _generate_write_long_content() -> Stripped:
+ """Generate the writer refusing an integer outside the safe integer range."""
+ return Stripped(
+ f"""\
+/**
+ * Write {{@code that}} as XML content, provided that it is a safe integer.
+ *
+ * This is the {{@link ContentWriter}} of every {{@code long}}-typed
+ * value, be it a property, a list item or a tuple item. An integer outside
+ * the safe integer range [-2^53 + 1, 2^53 - 1] is refused, exactly as in
+ * the JSON serialization, so that what XML holds JSON can hold as well.
+ */
+private static void writeLongContent(
+{I}Long that,
+{I}XMLStreamWriter writer) throws XMLStreamException {{
+{I}final long primitiveThat = that.longValue();
+{I}if (!Verification.isSafeInteger(primitiveThat)) {{
+{II}throw new XmlCommon.SerializeFailure(
+{III}new Reporting.Error(
+{IIII}Verification.integerOutsideSafeRangeMessage(primitiveThat)));
+{I}}}
+{I}writer.writeCharacters(Long.toString(primitiveThat));
+}}"""
+ )
+
+
# endregion
# region Writers of a single type
@@ -2862,6 +2924,9 @@ def _generate_visitor(
if Identifier("writeByteArrayContent") in needed.called_content_writers:
blocks.append(_generate_write_byte_array_content())
+ if Identifier("writeLongContent") in needed.called_content_writers:
+ blocks.append(_generate_write_long_content())
+
if Identifier("writeEnum") in needed.called_content_writers:
blocks.append(_generate_write_enum())
@@ -3056,6 +3121,12 @@ def generate(
]
)
+ # NOTE (mristin):
+ # An integer is checked against the safe integer range by the predicate of
+ # the verification, so that XML, JSON and the verification agree.
+ if intermediate_uses.integers(symbol_table):
+ imports.append(Stripped(f"import {package}.verification.Verification;"))
+
# NOTE (mristin):
# A JSON-able value is a Jackson node, and only the models which use one
# pay for the import and for the XML-RPC de/serialization which goes
diff --git a/aas_core_codegen/java/tests/_generate_test_jsonization_of_concrete_classes.py b/aas_core_codegen/java/tests/_generate_test_jsonization_of_concrete_classes.py
index 24bab5a85..1dd672a1e 100644
--- a/aas_core_codegen/java/tests/_generate_test_jsonization_of_concrete_classes.py
+++ b/aas_core_codegen/java/tests/_generate_test_jsonization_of_concrete_classes.py
@@ -3,6 +3,7 @@
from typing import List
from aas_core_codegen import intermediate, naming
+from aas_core_codegen.intermediate import uses as intermediate_uses
from aas_core_codegen.common import Identifier, Stripped, indent_but_first_line
from aas_core_codegen.java import common as java_common, naming as java_naming
from aas_core_codegen.java.common import (
@@ -45,6 +46,14 @@ def _generate_serialization_failure_tests(
# the path is a Java access path and not a JSON pointer.
getter_path = f"{getter_name}()"
+ # NOTE (mristin):
+ # A verification error, in contrast, is reported with the JSON path.
+ json_path = (
+ numeric_place.prop.json_name
+ if numeric_place.index is None
+ else f"{numeric_place.prop.json_name}[{numeric_place.index}]"
+ )
+
if numeric_place.index is None:
mutation = Stripped(f"instance.{setter_name}(value);")
expected_path = getter_path
@@ -128,11 +137,296 @@ def _generate_serialization_failure_tests(
)
)
- if len(result) > 0:
- result.insert(
- 0,
+ if numeric_place.a_type is not intermediate.PrimitiveType.INT:
+ continue
+
+ # NOTE (mristin):
+ # The verification has to refuse exactly the integers which the
+ # serializations refuse, so that a verified instance can always be
+ # serialized, to JSON and to XML alike.
+ outside_test_name = java_naming.method_name(
+ Identifier(
+ f"test_{numeric_place.cls.name}_{numeric_place.prop.name}"
+ f"_outside_the_safe_integer_range"
+ )
+ )
+
+ result.append(
+ Stripped(
+ f"""\
+@Test
+public void {outside_test_name}() throws IOException, XMLStreamException {{
+{I}for (long value : new long[] {{9007199254740992L, -9007199254740992L}}) {{
+{II}final {cls_name_java} instance =
+{III}Jsonization.Deserialize.deserialize{cls_name_java}(
+{IIII}loadTheFirstExpected({java_common.string_literal(cls_name_json)}));
+
+{II}{indent_but_first_line(mutation, II)}
+
+{II}final String expected =
+{III}"Expected an integer, but got " + value
+{IIII}+ ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]";
+
+{II}final List errors = verificationErrors(instance);
+{II}if (!errors.contains({java_common.string_literal(f"{json_path}: ")} + expected)) {{
+{III}fail("Expected the verification error " + expected + ", but got: " + errors);
+{II}}}
+
+{II}assertEquals(expected, jsonSerializationFailure(instance));
+{II}assertEquals(expected, xmlSerializationFailure(instance));
+{I}}}
+}} // public void {outside_test_name}"""
+ )
+ )
+
+ bounds_test_name = java_naming.method_name(
+ Identifier(
+ f"test_{numeric_place.cls.name}_{numeric_place.prop.name}"
+ f"_at_the_safe_integer_bounds"
+ )
+ )
+
+ result.append(
Stripped(
f"""\
+@Test
+public void {bounds_test_name}() throws IOException, XMLStreamException {{
+{I}for (long value : new long[] {{9007199254740991L, -9007199254740991L}}) {{
+{II}final {cls_name_java} instance =
+{III}Jsonization.Deserialize.deserialize{cls_name_java}(
+{IIII}loadTheFirstExpected({java_common.string_literal(cls_name_json)}));
+
+{II}{indent_but_first_line(mutation, II)}
+
+{II}assertNoSafeIntegerError(instance);
+{I}}}
+}} // public void {bounds_test_name}"""
+ )
+ )
+
+ return result
+
+
+def _generate_json_able_property_tests(
+ symbol_table: intermediate.SymbolTable,
+) -> List[Stripped]:
+ """
+ Generate the tests that a JSON-able property has to hold safe integers.
+
+ An integer is put into the JSON-able value of the property from outside,
+ and the verification, the JSON serialization and the XML serialization all
+ have to refuse it alike.
+ """
+ result: List[Stripped] = []
+
+ for cls in symbol_table.concrete_classes:
+ for prop in cls.properties:
+ type_anno = prop.type_annotation
+
+ # NOTE (mristin):
+ # We wrap the integer in an array or in an object with a plain key,
+ # so that the tests also cover the path within a JSON-able value.
+ # A constrained key would have to satisfy invariants which we do
+ # not know here.
+ build: Stripped
+ json_path: str
+ if isinstance(type_anno, intermediate.JsonValueTypeAnnotation):
+ build = Stripped(
+ "final JsonNode node = "
+ "JsonNodeFactory.instance.arrayNode().add(value);"
+ )
+ json_path = f"{prop.json_name}[0]"
+ elif isinstance(type_anno, intermediate.JsonArrayTypeAnnotation):
+ build = Stripped(
+ "final ArrayNode node = "
+ "JsonNodeFactory.instance.arrayNode().add(value);"
+ )
+ json_path = f"{prop.json_name}[0]"
+ elif (
+ isinstance(type_anno, intermediate.JsonObjectTypeAnnotation)
+ and intermediate.try_constrained_primitive(type_anno.key) is None
+ ):
+ build = Stripped(
+ "final ObjectNode node = "
+ 'JsonNodeFactory.instance.objectNode().put("key", value);'
+ )
+ json_path = f'{prop.json_name}["key"]'
+ else:
+ continue
+
+ cls_name_java = java_naming.class_name(cls.name)
+ cls_name_json = naming.json_model_type(cls.name)
+ setter_name = java_naming.method_name(Identifier(f"set_{prop.name}"))
+
+ outside_test_name = java_naming.method_name(
+ Identifier(
+ f"test_{cls.name}_{prop.name}_outside_the_safe_integer_range"
+ )
+ )
+
+ result.append(
+ Stripped(
+ f"""\
+@Test
+public void {outside_test_name}() throws IOException, XMLStreamException {{
+{I}for (long value : new long[] {{9007199254740992L, -9007199254740992L}}) {{
+{II}final {cls_name_java} instance =
+{III}Jsonization.Deserialize.deserialize{cls_name_java}(
+{IIII}loadTheFirstExpected({java_common.string_literal(cls_name_json)}));
+
+{II}{build}
+{II}instance.{setter_name}(node);
+
+{II}final String expected =
+{III}"Expected a JSON-able value, but got the integer " + value
+{IIII}+ ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]";
+
+{II}final List errors = verificationErrors(instance);
+{II}if (!errors.contains({java_common.string_literal(f"{json_path}: ")} + expected)) {{
+{III}fail("Expected the verification error " + expected + ", but got: " + errors);
+{II}}}
+
+{II}assertEquals(expected, jsonSerializationFailure(instance));
+{II}assertEquals(expected, xmlSerializationFailure(instance));
+{I}}}
+}} // public void {outside_test_name}"""
+ )
+ )
+
+ bounds_test_name = java_naming.method_name(
+ Identifier(f"test_{cls.name}_{prop.name}_at_the_safe_integer_bounds")
+ )
+
+ result.append(
+ Stripped(
+ f"""\
+@Test
+public void {bounds_test_name}() throws IOException, XMLStreamException {{
+{I}for (long value : new long[] {{9007199254740991L, -9007199254740991L}}) {{
+{II}final {cls_name_java} instance =
+{III}Jsonization.Deserialize.deserialize{cls_name_java}(
+{IIII}loadTheFirstExpected({java_common.string_literal(cls_name_json)}));
+
+{II}{build}
+{II}instance.{setter_name}(node);
+
+{II}assertNoSafeIntegerError(instance);
+{I}}}
+}} // public void {bounds_test_name}"""
+ )
+ )
+
+ return result
+
+
+def _generate_safe_integer_helpers() -> List[Stripped]:
+ """
+ Generate the helpers of the tests over the safe integer range.
+
+ The helpers render what the verification and the two serializations
+ report, so that a test can compare it against the expected message.
+ """
+ return [
+ Stripped(
+ f"""\
+/**
+ * Render the verification errors of {{@code instance}} as
+ * {{@code "path: cause"}}.
+ */
+private static List verificationErrors(IClass instance) {{
+{I}final List result = new ArrayList<>();
+{I}for (Reporting.Error error : Verification.verify(instance)) {{
+{II}result.add(
+{III}Reporting.generateJsonPath(error.getPathSegments())
+{IIII}+ ": " + error.getCause());
+{I}}}
+{I}return result;
+}}"""
+ ),
+ Stripped(
+ f"""\
+/**
+ * Serialize {{@code instance}} to JSON, and give the reason why it failed,
+ * or null if it did not.
+ */
+private static String jsonSerializationFailure(IClass instance) {{
+{I}try {{
+{II}Jsonization.Serialize.toJsonObject(instance);
+{II}return null;
+{I}}} catch (Jsonization.SerializeException exception) {{
+{II}return exception.getReason().orElse(null);
+{I}}}
+}}"""
+ ),
+ Stripped(
+ f"""\
+/**
+ * Serialize {{@code instance}} to XML, and give the reason why it failed,
+ * or null if it did not.
+ */
+private static String xmlSerializationFailure(IClass instance)
+{I}throws XMLStreamException {{
+{I}final XMLStreamWriter writer =
+{II}XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter());
+{I}try {{
+{II}Xmlization.Serialize.to(instance, writer);
+{II}return null;
+{I}}} catch (XmlCommon.SerializeException exception) {{
+{II}return exception.getReason().orElse(null);
+{I}}}
+}}"""
+ ),
+ Stripped(
+ f"""\
+/**
+ * Assert that neither the verification nor the serializations refuse
+ * an integer of {{@code instance}} as outside the safe integer range.
+ */
+private static void assertNoSafeIntegerError(IClass instance)
+{I}throws XMLStreamException {{
+{I}for (String error : verificationErrors(instance)) {{
+{II}if (error.contains("outside the safe integer range")) {{
+{III}fail("Expected no safe integer error, but got: " + error);
+{II}}}
+{I}}}
+
+{I}assertNull(jsonSerializationFailure(instance));
+{I}assertNull(xmlSerializationFailure(instance));
+}}"""
+ ),
+ ]
+
+
+def _generate_is_safe_integer_test() -> Stripped:
+ """Generate the test of the predicates over the safe integer range."""
+ return Stripped(
+ f"""\
+@Test
+public void testIsSafeInteger() {{
+{I}assertTrue(Verification.isSafeInteger(9007199254740991L));
+{I}assertTrue(Verification.isSafeInteger(-9007199254740991L));
+{I}assertFalse(Verification.isSafeInteger(9007199254740992L));
+{I}assertFalse(Verification.isSafeInteger(-9007199254740992L));
+
+{I}assertTrue(Verification.isSafeInteger(9007199254740991.0));
+{I}assertTrue(Verification.isSafeInteger(-9007199254740991.0));
+{I}assertFalse(Verification.isSafeInteger(9007199254740992.0));
+{I}assertFalse(Verification.isSafeInteger(-9007199254740992.0));
+
+{I}assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L)));
+{I}assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L)));
+{I}assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L)));
+{I}assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L)));
+{I}assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64)));
+{I}assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate()));
+}} // public void testIsSafeInteger"""
+ )
+
+
+def _generate_load_the_first_expected() -> Stripped:
+ """Generate the helper loading the first recorded JSON example of a class."""
+ return Stripped(
+ f"""\
private static JsonNode loadTheFirstExpected(String modelType) throws IOException {{
{I}final List paths =
{II}Common.findPaths(
@@ -149,10 +443,74 @@ def _generate_serialization_failure_tests(
{I}return CommonJson.readFromFile(paths.get(0));
}}"""
- ),
- )
+ )
- return result
+
+def _generate_json_value_safe_integer_tests(
+ symbol_table: intermediate.SymbolTable,
+) -> List[Stripped]:
+ """
+ Generate the tests that a JSON-able integer has to be a safe integer.
+
+ The verification, the de-serialization and the serialization of
+ a JSON-able value have to agree on the integers they refuse.
+ """
+ if not intermediate_uses.json_types(symbol_table):
+ return []
+
+ return [
+ Stripped(
+ f"""\
+@Test
+public void testJsonValueOutsideTheSafeIntegerRangeFails() {{
+{I}final JsonNode[] nodes = new JsonNode[] {{
+{II}JsonNodeFactory.instance.numberNode(9007199254740992L),
+{II}JsonNodeFactory.instance.numberNode(-9007199254740992L),
+{II}JsonNodeFactory.instance.numberNode(BigInteger.ONE.shiftLeft(64))
+{I}}};
+
+{I}for (JsonNode node : nodes) {{
+{II}final String expected =
+{III}"Expected a JSON-able value, but got the integer " + node
+{IIII}+ ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]";
+
+{II}final List got = new ArrayList<>();
+{II}Verification.verifyJsonValue(node).forEach(
+{III}error -> got.add(error.getCause()));
+{II}assertEquals(Arrays.asList(expected), got);
+
+{II}try {{
+{III}Jsonization.Deserialize.deserializeJsonValue(node);
+{III}fail("Expected the de-serialization of " + node + " to fail, but it succeeded");
+{II}}} catch (Jsonization.DeserializeException exception) {{
+{III}assertEquals(expected, exception.getReason().orElse(null));
+{II}}}
+
+{II}try {{
+{III}Jsonization.Serialize.jsonValueToJsonNode(node);
+{III}fail("Expected the serialization of " + node + " to fail, but it succeeded");
+{II}}} catch (Jsonization.SerializeException exception) {{
+{III}assertEquals(expected, exception.getReason().orElse(null));
+{II}}}
+{I}}}
+}} // public void testJsonValueOutsideTheSafeIntegerRangeFails"""
+ ),
+ Stripped(
+ f"""\
+@Test
+public void testJsonValueAtTheSafeIntegerBoundsOk() {{
+{I}for (long value : new long[] {{9007199254740991L, -9007199254740991L}}) {{
+{II}final JsonNode node = JsonNodeFactory.instance.numberNode(value);
+
+{II}assertEquals(0L, Verification.verifyJsonValue(node).count());
+
+{II}assertEquals(node, Jsonization.Deserialize.deserializeJsonValue(node));
+
+{II}assertEquals(node, Jsonization.Serialize.jsonValueToJsonNode(node));
+{I}}}
+}} // public void testJsonValueAtTheSafeIntegerBoundsOk"""
+ ),
+ ]
def generate(
@@ -344,8 +702,24 @@ def generate(
serialization_failure_tests = _generate_serialization_failure_tests(symbol_table)
+ json_able_property_tests = _generate_json_able_property_tests(symbol_table)
+
+ if len(serialization_failure_tests) > 0 or len(json_able_property_tests) > 0:
+ blocks.append(_generate_load_the_first_expected())
+
+ # NOTE (mristin):
+ # The helpers are only called by the tests over the integers, which are
+ # generated whenever an instance or a JSON-able value can hold one.
+ if intermediate_uses.safe_integer_checks(symbol_table):
+ blocks.extend(_generate_safe_integer_helpers())
+ blocks.append(_generate_is_safe_integer_test())
+
blocks.extend(serialization_failure_tests)
+ blocks.extend(json_able_property_tests)
+
+ blocks.extend(_generate_json_value_safe_integer_tests(symbol_table))
+
# NOTE (mristin):
# Only the tests of the serialization failures name a tuple, an interface
# or an enumeration of the meta-model, as they have to rebuild the value
@@ -371,6 +745,9 @@ def generate(
package {package}.tests;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import {package}.jsonization.Jsonization;
@@ -379,19 +756,29 @@ def generate(
import {package}.types.model.IClass;
{extra_imports}\
import {package}.verification.Verification;
+import {package}.xmlcommon.XmlCommon;
+import {package}.xmlization.Xmlization;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.FileNotFoundException;
import java.io.IOException;
+import java.io.StringWriter;
+import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamWriter;
import org.junit.jupiter.api.Test;
public class TestJsonizationOfConcreteClasses {{
diff --git a/aas_core_codegen/java/tests/_generate_test_xmlization_of_concrete_classes.py b/aas_core_codegen/java/tests/_generate_test_xmlization_of_concrete_classes.py
index 25527785e..8a1ee428b 100644
--- a/aas_core_codegen/java/tests/_generate_test_xmlization_of_concrete_classes.py
+++ b/aas_core_codegen/java/tests/_generate_test_xmlization_of_concrete_classes.py
@@ -58,6 +58,14 @@
("S G k =", "new byte[] {72, 105}"),
("", "new byte[] {}"),
],
+ intermediate.PrimitiveType.INT: [
+ # NOTE (mristin):
+ # The bounds of the safe integer range are read as they are. Just
+ # beyond them, the integer is refused, see
+ # :py:func:`_generate_outside_the_safe_integer_range_test`.
+ ("9007199254740991", "Long.valueOf(9007199254740991L)"),
+ ("-9007199254740991", "Long.valueOf(-9007199254740991L)"),
+ ],
intermediate.PrimitiveType.BOOL: [
# NOTE (mristin):
# ``xs:boolean`` spells the two values in four ways, not two.
@@ -90,6 +98,41 @@ def _lexical_test_name_chunk(text: str) -> str:
return "".join(mapping.get(character, character) for character in text)
+def _generate_outside_the_safe_integer_range_test(
+ prop: intermediate.Property,
+) -> Stripped:
+ """
+ Generate the test that an integer just outside the safe range is refused.
+
+ The text is a valid ``xs:long``, but the instance could not be written
+ to JSON, so the de-serialization refuses it with the very message of
+ the JSON de-serialization.
+ """
+ prop_xml_name = naming.xml_property(prop.name)
+
+ test_name = java_naming.method_name(
+ Identifier(f"test_{prop.name}_read_outside_the_safe_integer_range")
+ )
+
+ return Stripped(
+ f"""\
+@Test
+public void {test_name}() throws IOException, XMLStreamException {{
+{I}for (String text : new String[] {{"9007199254740992", "-9007199254740992"}}) {{
+{II}try {{
+{III}readWith({java_common.string_literal(prop_xml_name)}, text);
+{III}fail("Expected the de-serialization of " + text + " to fail, but it succeeded");
+{II}}} catch (XmlCommon.DeserializeException exception) {{
+{III}assertEquals(
+{IIII}"Expected an integer, but got " + text
+{IIIII}+ ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]",
+{IIII}exception.getReason().orElse(null));
+{II}}}
+{I}}}
+}} // public void {test_name}"""
+ )
+
+
def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stripped]:
"""Generate the tests over the lexical forms which no example can hold."""
cls = intermediate.first_class_of_only_required_primitives(symbol_table)
@@ -105,6 +148,7 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
(a_type, prop_by_a_type[a_type])
for a_type in (
intermediate.PrimitiveType.FLOAT,
+ intermediate.PrimitiveType.INT,
intermediate.PrimitiveType.BOOL,
intermediate.PrimitiveType.BYTEARRAY,
)
@@ -186,6 +230,9 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
)
)
+ if a_type is intermediate.PrimitiveType.INT:
+ result.append(_generate_outside_the_safe_integer_range_test(prop))
+
return result
diff --git a/aas_core_codegen/python/lib/_generate_common.py b/aas_core_codegen/python/lib/_generate_common.py
index 87bb6895b..d167d52e0 100644
--- a/aas_core_codegen/python/lib/_generate_common.py
+++ b/aas_core_codegen/python/lib/_generate_common.py
@@ -19,7 +19,11 @@
)
-#: Parse a text as a safe integer; this is how we transpile the built-in ``int``
+#: Parse a text as a safe integer; this is how we transpile the built-in ``int``.
+#:
+#: We limit ourselves to the safe integers, though the Python ``int`` could hold
+#: larger ones, as TypeScript represents the integers as ``number``, and
+#: the invariants must behave the same in all the SDKs.
_PARSE_SAFE_INT = Stripped(
f"""\
#: Match an optional sign followed by the ASCII digits.
@@ -39,6 +43,11 @@ def parse_safe_int(text: str) -> int:
{I}``2 ** 53 - 1``, which a double-precision floating-point number represents
{I}exactly.
+{I}We check the safe range even though :py:class:`int` could hold larger
+{I}integers. TypeScript represents the integers as ``number``, which holds them
+{I}exactly only within the safe range, and all the SDKs check the same range so
+{I}that the invariants which parse a text behave the same everywhere.
+
{I}:param text: to be parsed
{I}:return: parsed integer
{I}:raise: :py:class:`ValueError` if :paramref:`text` is not a safe integer
@@ -58,23 +67,17 @@ def parse_safe_int(text: str) -> int:
{I}# NOTE (mristin):
{I}# We count the significant digits before we call ``int``, as the conversion
{I}# is quadratic in the length of the text, and Python refuses a text of more
-{I}# than 4300 digits, leading zeros included.
+{I}# than 4300 digits, leading zeros included. A safe integer has at most
+{I}# 16 significant digits.
{I}digits = text.lstrip("+-").lstrip("0")
-{I}if len(digits) > 16:
-{II}raise ValueError(
-{III}f"Expected a safe integer, but got a text out of its range: {{text!r}}"
-{II})
-
-{I}value = int(digits) if len(digits) > 0 else 0
+{I}value = int(text) if len(digits) <= 16 else None
-{I}# NOTE (mristin):
-{I}# This is 2 ** 53 - 1.
-{I}if value > 9007199254740991:
+{I}if value is None or not is_safe_integer(value):
{II}raise ValueError(
{III}f"Expected a safe integer, but got a text out of its range: {{text!r}}"
{II})
-{I}return -value if text[0] == "-" else value"""
+{I}return value"""
)
@@ -188,51 +191,92 @@ def try_to_cast_to_array_like(
blocks.insert(len(blocks) - 1, _PARSE_SAFE_INT)
# NOTE (mristin):
- # Only a meta-model which uses a JSON-able type ever meets a bare number
- # whose type it does not already know, so the conversion is generated
- # only for such a model.
- if intermediate_uses.json_types(symbol_table):
+ # The range is checked by a single predicate, and each message is written by
+ # a single function, so that the JSON de-serialization, the JSON serialization
+ # and the verification can not drift apart: an instance which passes
+ # the verification is guaranteed to be serializable to JSON.
+ # The transpiled ``int`` calls, see ``parse_safe_int``, use the predicate as well.
+ integers = intermediate_uses.integers(symbol_table)
+ json_types = intermediate_uses.json_types(symbol_table)
+
+ if intermediate_uses.safe_integer_checks(symbol_table):
blocks.insert(
len(blocks) - 1,
Stripped(
f'''\
-def try_to_convert_int_to_float(
+def is_safe_integer(
{I}value: int
-) -> Optional[float]:
+) -> bool:
{I}"""
-{I}Try to convert :paramref:`value` to the ``float`` which JSON prescribes.
+{I}Check that :paramref:`value` lies in the safe integer range.
-{I}JSON knows a single numeric type, so an ``int`` has to become a ``float``
-{I}on its way into and out of a JSON-able value. The conversion has to be
-{I}exact: a ``float`` which does not convert back to :paramref:`value` would
-{I}quietly say a different number than the one which was given, so such
-{I}a value is refused instead of rounded.
+{I}The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which
+{I}a double-precision floating-point number represents exactly. JSON knows
+{I}a single numeric type, and the JSON de-serializers of the other languages
+{I}read a number into such a float, so an integer outside of the range might
+{I}quietly come out as a different number.
{I}Mind that a ``bool`` is an ``int`` in Python. The callers all sort out
{I}the booleans before they come here.
-{I}>>> try_to_convert_int_to_float(1)
-{I}1.0
+{I}:param value: integer to be checked
+{I}:return: True if :paramref:`value` is a safe integer
-{I}>>> try_to_convert_int_to_float(-3)
-{I}-3.0
+{I}>>> is_safe_integer(2 ** 53 - 1)
+{I}True
-{I}>>> assert try_to_convert_int_to_float(2 ** 53 + 1) is None
+{I}>>> is_safe_integer(-(2 ** 53 - 1))
+{I}True
-{I}>>> assert try_to_convert_int_to_float(10 ** 400) is None
+{I}>>> is_safe_integer(2 ** 53)
+{I}False
+
+{I}>>> is_safe_integer(-(2 ** 53))
+{I}False
{I}"""
-{I}try:
-{II}result = float(value)
-{I}except OverflowError:
-{II}# NOTE (mristin):
-{II}# A Python ``int`` is unbounded, so one can be far too large for
-{II}# a ``float`` to hold at all.
-{II}return None
+{I}return -9007199254740991 <= value <= 9007199254740991'''
+ ),
+ )
-{I}if int(result) != value:
-{II}return None
+ if integers:
+ blocks.insert(
+ len(blocks) - 1,
+ Stripped(
+ f'''\
+def unsafe_integer_message(
+{I}value: int
+) -> str:
+{I}"""
+{I}Describe that :paramref:`value` lies outside the safe integer range.
+
+{I}See :py:func:`is_safe_integer`.
+{I}"""
+{I}return (
+{II}f"Expected an integer, but got {{value}}, "
+{II}f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"
+{I})'''
+ ),
+ )
+
+ if json_types:
+ blocks.insert(
+ len(blocks) - 1,
+ Stripped(
+ f'''\
+def unsafe_json_able_integer_message(
+{I}value: int
+) -> str:
+{I}"""
+{I}Describe that :paramref:`value` in a JSON-able value is not a safe integer.
-{I}return result'''
+{I}JSON knows a single numeric type, so an ``int`` has to become a ``float``
+{I}on its way into and out of a JSON-able value. Only a safe integer survives
+{I}the conversion exactly, see :py:func:`is_safe_integer`.
+{I}"""
+{I}return (
+{II}f"Expected a JSON-able value, but got the integer {{value}}, "
+{II}f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"
+{I})'''
),
)
diff --git a/aas_core_codegen/python/lib/_generate_json_value_verification.py b/aas_core_codegen/python/lib/_generate_json_value_verification.py
index 21bee75b4..4c14684dc 100644
--- a/aas_core_codegen/python/lib/_generate_json_value_verification.py
+++ b/aas_core_codegen/python/lib/_generate_json_value_verification.py
@@ -103,12 +103,9 @@ def verify_json_value(
{II}# JSON knows a single numeric type, so an ``int`` becomes a ``float``
{II}# on the way out. Only an ``int`` which that conversion can not carry
{II}# exactly is an error here -- see
-{II}# :py:func:`.common.try_to_convert_int_to_float`.
-{II}if our_common.try_to_convert_int_to_float(value) is None:
-{III}yield Error(
-{IIII}f"Expected a JSON-able value, but got the integer {{value}}, "
-{IIII}f"which is not exactly representable as a JSON number"
-{III})
+{II}# :py:func:`.common.is_safe_integer`.
+{II}if not our_common.is_safe_integer(value):
+{III}yield Error(our_common.unsafe_json_able_integer_message(value))
{II}return
diff --git a/aas_core_codegen/python/lib/_generate_jsonization.py b/aas_core_codegen/python/lib/_generate_jsonization.py
index 1ceaf0387..f0bb69c40 100644
--- a/aas_core_codegen/python/lib/_generate_jsonization.py
+++ b/aas_core_codegen/python/lib/_generate_jsonization.py
@@ -17,6 +17,7 @@
from icontract import ensure, require
from aas_core_codegen import intermediate, naming
+from aas_core_codegen.intermediate import uses as intermediate_uses
from aas_core_codegen.common import (
Error,
Stripped,
@@ -78,13 +79,13 @@ def _parser_name(type_annotation: intermediate.TypeAnnotationUnion) -> Identifie
raise AssertionError("Expected to handle this case before")
elif isinstance(type_anno, intermediate.JsonValueTypeAnnotation):
- return Identifier("_json_value_from_jsonable")
+ return Identifier("json_value_from_jsonable")
elif isinstance(type_anno, intermediate.JsonArrayTypeAnnotation):
- return Identifier("_json_array_from_jsonable")
+ return Identifier("json_array_from_jsonable")
elif isinstance(type_anno, intermediate.JsonObjectTypeAnnotation):
- return Identifier("_json_object_from_jsonable")
+ return Identifier("json_object_from_jsonable")
elif isinstance(type_anno, intermediate.OurTypeAnnotation):
our_type = type_anno.our_type
@@ -360,9 +361,9 @@ def register_parser(
"_bytes_from_jsonable": [],
"_list_from_jsonable": [],
"_set_from_jsonable": [],
- "_json_value_from_jsonable": [],
- "_json_array_from_jsonable": ["_json_value_from_jsonable"],
- "_json_object_from_jsonable": ["_json_value_from_jsonable"],
+ "json_value_from_jsonable": [],
+ "json_array_from_jsonable": ["json_value_from_jsonable"],
+ "json_object_from_jsonable": ["json_value_from_jsonable"],
}
@@ -501,6 +502,13 @@ def _int_from_jsonable(
{I}"""
{I}Parse :paramref:`jsonable` as an integer.
+{I}A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has
+{I}a single number type, so ``5.0`` is every bit as good an integer as ``5``
+{I}is, and :py:mod:`json` gives us a ``float`` for the former.
+
+{I}Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are
+{I}accepted, see :py:func:`.common.is_safe_integer`.
+
{I}:param jsonable: JSON-able structure to be parsed
{I}:return: parsed integer
{I}:raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable`
@@ -508,11 +516,31 @@ def _int_from_jsonable(
{I}# NOTE (mristin):
{I}# ``bool`` is a subclass of ``int`` in Python, so it has to be excluded
{I}# explicitly. Otherwise ``True`` would be read as ``1``.
-{I}if isinstance(jsonable, bool) or not isinstance(jsonable, int):
+{I}if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)):
{II}raise DeserializationException(
-{III}f"Expected an int, but got: {{type(jsonable)}}"
+{III}f"Expected an integer, but got: {{type(jsonable)}}"
{II})
-{I}return jsonable'''
+
+{I}value: int
+{I}if isinstance(jsonable, float):
+{II}# NOTE (mristin):
+{II}# ``is_integer`` is false for an infinity and a not-a-number as well.
+{II}if not jsonable.is_integer():
+{III}raise DeserializationException(
+{IIII}f"Expected an integer, but got {{jsonable}}, "
+{IIII}f"which is not a whole number"
+{III})
+
+{II}value = int(jsonable)
+{I}else:
+{II}value = jsonable
+
+{I}if not our_common.is_safe_integer(value):
+{II}raise DeserializationException(
+{III}our_common.unsafe_integer_message(value)
+{II})
+
+{I}return value'''
),
"_float_from_jsonable": Stripped(
f'''\
@@ -672,9 +700,9 @@ def _set_from_jsonable(
{I}return result'''
),
- "_json_value_from_jsonable": Stripped(
+ "json_value_from_jsonable": Stripped(
f'''\
-def _json_value_from_jsonable(
+def json_value_from_jsonable(
{I}jsonable: Jsonable
) -> our_types.JsonValue:
{I}"""
@@ -707,14 +735,12 @@ def _json_value_from_jsonable(
{II}# JSON knows a single numeric type, so :py:mod:`json` giving us
{II}# an ``int`` says nothing about the document: "1" and "1.0" are
{II}# the same number, and a JSON-able value holds it as a ``float``.
-{II}number = our_common.try_to_convert_int_to_float(jsonable)
-{II}if number is None:
+{II}if not our_common.is_safe_integer(jsonable):
{III}raise DeserializationException(
-{IIII}f"Expected a JSON-able value, but got the integer {{jsonable}}, "
-{IIII}f"which is not exactly representable as a JSON number"
+{IIII}our_common.unsafe_json_able_integer_message(jsonable)
{III})
-{II}return number
+{II}return float(jsonable)
{I}if isinstance(jsonable, float):
{II}# NOTE (mristin):
@@ -742,7 +768,7 @@ def _json_value_from_jsonable(
{IIII})
{III}try:
-{IIII}mapping[key] = _json_value_from_jsonable(jsonable_value)
+{IIII}mapping[key] = json_value_from_jsonable(jsonable_value)
{III}except DeserializationException as exception:
{IIII}exception.path._prepend(KeySegment(jsonable, key))
{IIII}raise
@@ -755,7 +781,7 @@ def _json_value_from_jsonable(
{II}items: List[Any] = []
{II}for i, jsonable_item in enumerate(array_like):
{III}try:
-{IIII}items.append(_json_value_from_jsonable(jsonable_item))
+{IIII}items.append(json_value_from_jsonable(jsonable_item))
{III}except DeserializationException as exception:
{IIII}exception.path._prepend(IndexSegment(array_like, i))
{IIII}raise
@@ -767,9 +793,9 @@ def _json_value_from_jsonable(
{II}f"an array or an object), but got: {{type(jsonable)}}"
{I})'''
),
- "_json_array_from_jsonable": Stripped(
+ "json_array_from_jsonable": Stripped(
f'''\
-def _json_array_from_jsonable(
+def json_array_from_jsonable(
{I}jsonable: Jsonable
) -> our_types.JsonArray:
{I}"""
@@ -784,13 +810,13 @@ def _json_array_from_jsonable(
{III}f"Expected a JSON-able array, but got: {{type(jsonable)}}"
{II})
-{I}result = _json_value_from_jsonable(jsonable)
+{I}result = json_value_from_jsonable(jsonable)
{I}assert isinstance(result, list)
{I}return result'''
),
- "_json_object_from_jsonable": Stripped(
+ "json_object_from_jsonable": Stripped(
f'''\
-def _json_object_from_jsonable(
+def json_object_from_jsonable(
{I}jsonable: Jsonable
) -> our_types.JsonObject:
{I}"""
@@ -805,7 +831,7 @@ def _json_object_from_jsonable(
{III}f"Expected a JSON-able object, but got: {{type(jsonable)}}"
{II})
-{I}result = _json_value_from_jsonable(jsonable)
+{I}result = json_value_from_jsonable(jsonable)
{I}assert isinstance(result, dict)
{I}return result'''
),
@@ -1710,7 +1736,7 @@ def _generate_json_value_to_jsonable() -> Stripped:
"""Generate the function serializing a JSON-able value."""
return Stripped(
f'''\
-def _json_value_to_jsonable(
+def json_value_to_jsonable(
{I}value: our_types.JsonValue
) -> MutableJsonable:
{I}"""
@@ -1740,13 +1766,12 @@ def _json_value_to_jsonable(
{I}if isinstance(value, int):
{II}# NOTE (mristin):
{II}# An ``int`` is written out as it stands. JSON knows a single
-{II}# numeric type, though, so it has to be one which a ``float`` can
-{II}# hold exactly -- otherwise a reader, which reads every number as
-{II}# a ``float``, would get a different number back.
-{II}if our_common.try_to_convert_int_to_float(value) is None:
+{II}# numeric type, though, so it has to lie in the safe integer range
+{II}# [-2^53 + 1, 2^53 - 1] -- otherwise a reader, which reads every number
+{II}# as a ``float``, might get a different number back.
+{II}if not our_common.is_safe_integer(value):
{III}raise SerializationException(
-{IIII}f"Expected a JSON-able value, but got the integer {{value}}, "
-{IIII}f"which is not exactly representable as a JSON number"
+{IIII}our_common.unsafe_json_able_integer_message(value)
{III})
{II}return value
@@ -1773,7 +1798,7 @@ def _json_value_to_jsonable(
{IIII})
{III}try:
-{IIII}mapping[key] = _json_value_to_jsonable(item_value)
+{IIII}mapping[key] = json_value_to_jsonable(item_value)
{III}except SerializationException as exception:
{IIII}exception._prepend_key(key)
{IIII}raise
@@ -1786,7 +1811,7 @@ def _json_value_to_jsonable(
{II}items: List[Any] = []
{II}for i, item_value in enumerate(array_like):
{III}try:
-{IIII}items.append(_json_value_to_jsonable(item_value))
+{IIII}items.append(json_value_to_jsonable(item_value))
{III}except SerializationException as exception:
{IIII}exception._prepend_index(i)
{IIII}raise
@@ -1856,7 +1881,7 @@ def _generate_atomic_serialization(
# known, and the function gives back whatever it is given.
return Stripped(
f"""\
-_json_value_to_jsonable(
+json_value_to_jsonable(
{I}{access_expression}
)"""
)
@@ -2357,20 +2382,18 @@ def _int_to_jsonable(
{I}"""
{I}Serialize :paramref:`that` integer to a JSON-able value.
-{I}Only the integers in the range :math:`[-2^{{53}} + 1, 2^{{53}} - 1]` are
-{I}serialized. Outside of it, an integer can not be exactly represented as
-{I}a 64-bit floating-point number, which is what the JSON de-serializers of
-{I}the other languages read a number into.
+{I}Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are
+{I}serialized, see :py:func:`.common.is_safe_integer`.
{I}:param that: integer to be serialized
{I}:return: :paramref:`that`, unchanged
{I}:raise: :py:class:`SerializationException` if outside the range
{I}"""
-{I}if that < -9007199254740991 or that > 9007199254740991:
+{I}if not our_common.is_safe_integer(that):
{II}raise SerializationException(
-{III}f"The integer can not be serialized to JSON as it is outside "
-{III}f"the range [-2^53 + 1, 2^53 - 1]: {{that}}"
+{III}our_common.unsafe_integer_message(that)
{II})
+
{I}return that'''
),
Stripped(
@@ -2572,6 +2595,17 @@ def generate(
):
registry.note_needed_helper("_dispatch_from_jsonable")
+ # NOTE (mristin):
+ # The JSON-able values are de/serialized by public functions, so that the clients
+ # of the SDK can use them as well. Hence we generate all of them as soon as
+ # the meta-model uses any JSON-able type, and not only the ones which
+ # the properties reach.
+ if intermediate_uses.json_types(symbol_table):
+ registry.note_needed_helper("json_value_from_jsonable")
+ registry.note_needed_helper("json_array_from_jsonable")
+ registry.note_needed_helper("json_object_from_jsonable")
+ serializer_registry.note_json_values_are_serialized()
+
helper_blocks = _generate_deserialization_helpers()
needed_helpers = _collect_needed_helpers(registry.needed_helpers)
@@ -2596,7 +2630,7 @@ def generate(
if (
"_float_from_jsonable" in needed_helpers
or intermediate.PrimitiveType.FLOAT in serializer_registry.checked_numbers
- or "_json_value_from_jsonable" in needed_helpers
+ or "json_value_from_jsonable" in needed_helpers
or serializer_registry.json_values_are_serialized
)
else ""
diff --git a/aas_core_codegen/python/lib/_generate_verification.py b/aas_core_codegen/python/lib/_generate_verification.py
index 856344a05..09601f531 100644
--- a/aas_core_codegen/python/lib/_generate_verification.py
+++ b/aas_core_codegen/python/lib/_generate_verification.py
@@ -3,6 +3,7 @@
import textwrap
from typing import (
Dict,
+ Final,
Tuple,
Optional,
List,
@@ -675,8 +676,9 @@ def _needs_verification(type_annotation: intermediate.TypeAnnotationUnion) -> bo
"""
Check whether a value of ``type_annotation`` has anything to verify at any depth.
- In Python, we verify the constrained primitives, the instances of our classes
- and all the JSON-able values. We rely on mypy to spot invalid enumeration
+ In Python, we verify the constrained primitives, the instances of our classes,
+ all the JSON-able values and the integers, which have to lie in the safe integer
+ range to be serializable to JSON. We rely on mypy to spot invalid enumeration
literals, so we do not verify the enumerations at all.
"""
return any(
@@ -684,6 +686,10 @@ def _needs_verification(type_annotation: intermediate.TypeAnnotationUnion) -> bo
isinstance(type_anno, intermediate.OurTypeAnnotation)
and not isinstance(type_anno.our_type, intermediate.Enumeration)
)
+ or (
+ isinstance(type_anno, intermediate.PrimitiveTypeAnnotation)
+ and type_anno.a_type is intermediate.PrimitiveType.INT
+ )
or isinstance(
type_anno,
(
@@ -785,6 +791,10 @@ def _verification_parameter_type(type_anno: intermediate.TypeAnnotationUnion) ->
return python_common.generate_type(type_anno, types_module=Identifier("our_types"))
+#: Name of the module-level function verifying an integer
+_VERIFY_INT: Final[Identifier] = Identifier("_verify_int")
+
+
def _verify_container_name(
type_anno: intermediate.ContainerTypeAnnotation,
) -> Identifier:
@@ -822,6 +832,13 @@ def _generate_verify_into(
)
function = "verify"
+ elif isinstance(type_anno, intermediate.PrimitiveTypeAnnotation):
+ assert type_anno.a_type is intermediate.PrimitiveType.INT, (
+ f"Unexpected primitive type with something to verify: "
+ f"{type_anno.a_type}"
+ )
+ function = _VERIFY_INT
+
elif isinstance(type_anno, intermediate.JsonValueTypeAnnotation):
function = "our_json_value_verification.verify_json_value"
@@ -1159,6 +1176,12 @@ def _generate_verify_constrained_primitive(
if len(errors) > 0:
return None, errors
+ # NOTE (mristin):
+ # The constrainee has to be serializable to JSON in the first place, so we
+ # check its range before its invariants.
+ if constrained_primitive.constrainee is intermediate.PrimitiveType.INT:
+ blocks.insert(0, Stripped(f"yield from {_VERIFY_INT}(that)"))
+
no_verification_specified = False
if len(blocks) == 0:
no_verification_specified = True
@@ -1296,7 +1319,9 @@ def generate(
"reporting as our_reporting",
"types as our_types",
]
- if intermediate_uses.int_call(symbol_table):
+ if intermediate_uses.int_call(symbol_table) or intermediate_uses.integers(
+ symbol_table
+ ):
imported_modules.insert(0, "common as our_common")
imported_modules_joined = "\n".join(
@@ -1470,6 +1495,19 @@ def verify(
)
)
+ if intermediate_uses.integers(symbol_table):
+ blocks.append(
+ Stripped(
+ f"""\
+def {_VERIFY_INT}(
+{II}that: int
+) -> Iterator[Error]:
+{I}\"\"\"Verify that :paramref:`that` lies in the safe integer range.\"\"\"
+{I}if not our_common.is_safe_integer(that):
+{II}yield Error(our_common.unsafe_integer_message(that))"""
+ )
+ )
+
blocks.extend(container_blocks_by_name.values())
for our_type in symbol_table.our_types:
diff --git a/aas_core_codegen/python/lib/_generate_xml_rpc.py b/aas_core_codegen/python/lib/_generate_xml_rpc.py
index 94edd0c01..746eec439 100644
--- a/aas_core_codegen/python/lib/_generate_xml_rpc.py
+++ b/aas_core_codegen/python/lib/_generate_xml_rpc.py
@@ -480,13 +480,12 @@ def write_discriminator(
{II}# NOTE (mristin):
{II}# An ``int`` is written out as it stands, free of the fraction which
{II}# a ``float`` carries. JSON knows a single numeric type, though, so
-{II}# it has to be one which a ``float`` can hold exactly -- otherwise
-{II}# a reader, which reads every as a ``float``, would get
+{II}# it has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] -- otherwise
+{II}# a reader, which reads every as a ``float``, might get
{II}# a different number back.
-{II}if our_common.try_to_convert_int_to_float(value) is None:
+{II}if not our_common.is_safe_integer(value):
{III}raise SerializationException(
-{IIII}f"Expected a JSON-able value, but got the integer {{value}}, "
-{IIII}f"which is not exactly representable as a JSON number"
+{IIII}our_common.unsafe_json_able_integer_message(value)
{III})
{II}writer.write_start_element_in_no_namespace('double')
diff --git a/aas_core_codegen/python/lib/_generate_xmlization.py b/aas_core_codegen/python/lib/_generate_xmlization.py
index dc90e5d7f..18e41a460 100644
--- a/aas_core_codegen/python/lib/_generate_xmlization.py
+++ b/aas_core_codegen/python/lib/_generate_xmlization.py
@@ -3379,7 +3379,8 @@ def _read_int_from_element_text(
{II}``events=["start", "end"]``
{I}:raise:
{II}:py:class:`DeserializationException` if unexpected input, including
-{II}a value outside of the 64-bit range of ``xs:long``
+{II}a value outside of the 64-bit range of ``xs:long`` and outside of
+{II}the safe integer range, see :py:func:`.common.is_safe_integer`
{I}:return: parsed value
{I}\"\"\"
{I}text = collapse_whitespace(
@@ -3430,6 +3431,15 @@ def _read_int_from_element_text(
{III}f"but got an element with text out of its range: {{text!r}}"
{II})
+{I}# NOTE (mristin):
+{I}# A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse
+{I}# the values outside of the safe integer range, as the JSON de-serialization
+{I}# and the verification do.
+{I}if not our_common.is_safe_integer(value):
+{II}raise DeserializationException(
+{III}our_common.unsafe_integer_message(value)
+{II})
+
{I}return value"""
),
"_read_float_from_element_text": Stripped(
@@ -3730,6 +3740,14 @@ def _write_int_as_element(
{I}:raise: :py:class:`SerializationException` if the value could not be written
{I}\"\"\"
{I}try:
+{II}# NOTE (mristin):
+{II}# We refuse the values outside of the safe integer range, as the JSON
+{II}# serialization and the verification do.
+{II}if not our_common.is_safe_integer(value):
+{III}raise SerializationException(
+{IIII}our_common.unsafe_integer_message(value)
+{III})
+
{II}serializer.writer.write_start_element(name)
{II}serializer.writer.stream.write(str(value))
{II}serializer.writer.write_end_element(name)
@@ -4161,6 +4179,24 @@ def generate(
else ""
)
+ # NOTE (mristin):
+ # The integers are checked against the safe integer range by the common
+ # module. We import it only if a property reaches an integer, which is
+ # exactly when the integers are read and written, so that the import is
+ # never unused.
+ common_import = (
+ f"import {qualified_module_name}.common as our_common\n"
+ if any(
+ intermediate.try_primitive_type(type_anno) is intermediate.PrimitiveType.INT
+ for cls in symbol_table.concrete_classes
+ for prop in cls.properties
+ for type_anno in intermediate.over_type_annotation_and_nested_type_annotations(
+ prop.type_annotation
+ )
+ )
+ else ""
+ )
+
# NOTE (mristin):
# We import ``Set`` only for the sets in the properties so that the import is
# never unused.
@@ -4204,7 +4240,7 @@ def generate(
else:
{I}from typing_extensions import Final
-import {qualified_module_name}.stringification as our_stringification
+{common_import}import {qualified_module_name}.stringification as our_stringification
import {qualified_module_name}.types as our_types
import {qualified_module_name}.xmlcommon as our_xmlcommon
{xmlrpc_import}from {qualified_module_name}.xmlcommon import (
diff --git a/aas_core_codegen/python/main.py b/aas_core_codegen/python/main.py
index 953c6d973..9e8a807f4 100644
--- a/aas_core_codegen/python/main.py
+++ b/aas_core_codegen/python/main.py
@@ -407,6 +407,23 @@ def execute(context: run.Context, stdout: TextIO, stderr: TextIO) -> int:
),
]
+ # NOTE (mristin):
+ # We test the safe integer range whenever the SDK checks it, see
+ # :py:func:`intermediate_uses.safe_integer_checks`.
+ if intermediate_uses.safe_integer_checks(context.symbol_table):
+ rel_paths_generators = list(rel_paths_generators) + [
+ (
+ tests_rel_path / "test_safe_integers.py",
+ lambda: (
+ python_tests.generate_test_safe_integers(
+ symbol_table=context.symbol_table,
+ qualified_module_name=qualified_module_name,
+ ),
+ None,
+ ),
+ ),
+ ]
+
for rel_path, generator_func in rel_paths_generators:
assert not rel_path.is_absolute()
diff --git a/aas_core_codegen/python/tests/__init__.py b/aas_core_codegen/python/tests/__init__.py
index 27af89713..e7d778173 100644
--- a/aas_core_codegen/python/tests/__init__.py
+++ b/aas_core_codegen/python/tests/__init__.py
@@ -10,6 +10,7 @@
_generate_test_json_value_verification,
_generate_test_string_helpers,
_generate_test_parse_int,
+ _generate_test_safe_integers,
_generate_test_jsonization_of_classes_with_descendants,
_generate_test_jsonization_of_concrete_classes,
_generate_test_jsonization_of_enums,
@@ -32,6 +33,7 @@
generate_test_json_value_verification = _generate_test_json_value_verification.generate
generate_test_string_helpers = _generate_test_string_helpers.generate
generate_test_parse_int = _generate_test_parse_int.generate
+generate_test_safe_integers = _generate_test_safe_integers.generate
generate_test_jsonization_of_classes_with_descendants = (
_generate_test_jsonization_of_classes_with_descendants.generate
)
diff --git a/aas_core_codegen/python/tests/_generate_test_json_value_verification.py b/aas_core_codegen/python/tests/_generate_test_json_value_verification.py
index 988458d46..0526abf3f 100644
--- a/aas_core_codegen/python/tests/_generate_test_json_value_verification.py
+++ b/aas_core_codegen/python/tests/_generate_test_json_value_verification.py
@@ -147,7 +147,7 @@ class TestJsonValue(unittest.TestCase):
{III})
{I}def test_an_integer_is_json_able_while_it_fits_a_float(self) -> None:
-{II}for number in (0, 1, -3, 2 ** 53):
+{II}for number in (0, 1, -3, 2 ** 53 - 1, -(2 ** 53 - 1)):
{III}self.assertListEqual(
{IIII}[],
{IIII}_paths_and_causes(
@@ -159,18 +159,19 @@ class TestJsonValue(unittest.TestCase):
{I}def test_an_integer_too_large_for_a_float_is_refused(self) -> None:
{II}# NOTE (mristin):
{II}# JSON knows a single numeric type, so an ``int`` has to become
-{II}# a ``float``. 2**53 + 1 is the first whole number which a ``float``
-{II}# can not tell from its neighbour, and 10**400 exceeds a ``float``
-{II}# altogether.
-{II}for number in (2 ** 53 + 1, 10 ** 400):
+{II}# a ``float``. 2**53 is the first integer outside the safe integer
+{II}# range [-2^53 + 1, 2^53 - 1], as for the integer properties of
+{II}# the model, and 10**400 exceeds a ``float`` altogether.
+{II}for number in (2 ** 53, -(2 ** 53), 10 ** 400):
{III}paths_and_causes = _paths_and_causes(
{IIII}our_json_value_verification.verify_json_value([number])
{III})
{III}self.assertEqual(1, len(paths_and_causes), f"for the number {{number}}")
{III}self.assertEqual("[0]", paths_and_causes[0][0])
-{III}self.assertIn(
-{IIII}"not exactly representable as a JSON number",
+{III}self.assertEqual(
+{IIII}f"Expected a JSON-able value, but got the integer {{number}}, "
+{IIII}f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]",
{IIII}paths_and_causes[0][1]
{III})
diff --git a/aas_core_codegen/python/tests/_generate_test_jsonization_of_concrete_classes.py b/aas_core_codegen/python/tests/_generate_test_jsonization_of_concrete_classes.py
index 8dba6923a..078fb2c69 100644
--- a/aas_core_codegen/python/tests/_generate_test_jsonization_of_concrete_classes.py
+++ b/aas_core_codegen/python/tests/_generate_test_jsonization_of_concrete_classes.py
@@ -1,7 +1,7 @@
"""Generate code to test the JSON de/serialization of concrete classes."""
import io
-from typing import List
+from typing import List, Tuple
from icontract import ensure
@@ -12,6 +12,7 @@
INDENT as I,
INDENT2 as II,
INDENT3 as III,
+ INDENT4 as IIII,
)
@@ -71,73 +72,99 @@ class TestRoundTrips(unittest.TestCase):
)
+def numeric_place_mutation(
+ numeric_place: intermediate.NumericPlace, zero_literal: str
+) -> Tuple[Stripped, str]:
+ """
+ Generate the statement which puts ``value`` at the ``numeric_place`` of ``instance``.
+
+ Return the statement and the subscript, if any, which follows the property in
+ the path to the value.
+
+ :param numeric_place: where the value is put
+ :param zero_literal: to fill the items before the value in a list
+ :return: statement, and subscript of the path (empty if none)
+ """
+ prop_name = python_naming.property_name(numeric_place.prop.name)
+
+ if numeric_place.index is None:
+ return Stripped(f"instance.{prop_name} = value"), ""
+
+ if numeric_place.in_list:
+ # NOTE (mristin):
+ # The value goes to the position indicated by the numeric place so
+ # that a serializer which always reports the index 0 does not pass.
+ items_joined = ", ".join(
+ "value" if i == numeric_place.index else zero_literal
+ for i in range(numeric_place.index + 1)
+ )
+
+ return (
+ Stripped(f"instance.{prop_name} = [{items_joined}]"),
+ f"[{numeric_place.index}]",
+ )
+
+ tuple_type_anno = numeric_place.prop.type_annotation
+ assert isinstance(tuple_type_anno, intermediate.TupleTypeAnnotation), (
+ f"Expected a tuple at the numeric place of "
+ f"{numeric_place.cls.name}.{numeric_place.prop.name}, "
+ f"but got: {tuple_type_anno}"
+ )
+
+ # NOTE (mristin):
+ # A tuple is immutable, so it has to be rebuilt around the value.
+ items_joined = ", ".join(
+ "value" if i == numeric_place.index else f"instance.{prop_name}[{i}]"
+ for i in range(len(tuple_type_anno.items))
+ )
+
+ return (
+ Stripped(f"instance.{prop_name} = ({items_joined})"),
+ f"[{numeric_place.index}]",
+ )
+
+
def _generate_serialization_failure_test_case(
symbol_table: intermediate.SymbolTable,
) -> Stripped:
- """Generate the tests that a number unrepresentable in JSON is refused."""
+ """
+ Generate the tests that a number unrepresentable in JSON is refused.
+
+ The integers outside of the safe integer range are tested together with
+ the verification and the XML serialization in ``test_safe_integers.py``, so
+ only the non-finite floats are tested here. The JSON de-serialization of
+ the integers is tested here, though.
+ """
test_methods: List[Stripped] = []
+ int_parsing_methods: List[Stripped] = []
for numeric_place in intermediate.numeric_places(symbol_table):
- if numeric_place.a_type is intermediate.PrimitiveType.FLOAT:
- what = "non_finite"
- zero_literal = "0.0"
- values_literal = "[math.inf, -math.inf, math.nan]"
- else:
- what = "out_of_range"
- zero_literal = "0"
- values_literal = "[9007199254740992, -9007199254740992]"
-
prop_name = python_naming.property_name(numeric_place.prop.name)
- if numeric_place.index is None:
- mutation = Stripped(f"instance.{prop_name} = value")
- expected_path = f".{numeric_place.prop.name}"
- elif numeric_place.in_list:
- # NOTE (mristin):
- # The value goes to the position indicated by the numeric place so
- # that a serializer which always reports the index 0 does not pass.
- items_joined = ", ".join(
- "value" if i == numeric_place.index else zero_literal
- for i in range(numeric_place.index + 1)
- )
-
- mutation = Stripped(f"instance.{prop_name} = [{items_joined}]")
- expected_path = f".{numeric_place.prop.name}[{numeric_place.index}]"
- else:
- tuple_type_anno = numeric_place.prop.type_annotation
- assert isinstance(tuple_type_anno, intermediate.TupleTypeAnnotation), (
- f"Expected a tuple at the numeric place of "
- f"{numeric_place.cls.name}.{numeric_place.prop.name}, "
- f"but got: {tuple_type_anno}"
- )
-
- # NOTE (mristin):
- # A tuple is immutable, so it has to be rebuilt around the value.
- items_joined = ", ".join(
- "value" if i == numeric_place.index else f"instance.{prop_name}[{i}]"
- for i in range(len(tuple_type_anno.items))
- )
-
- mutation = Stripped(f"instance.{prop_name} = ({items_joined})")
- expected_path = f".{numeric_place.prop.name}[{numeric_place.index}]"
-
model_type = naming.json_model_type(numeric_place.cls.name)
from_jsonable = python_naming.function_name(
Identifier(f"{numeric_place.cls.name}_from_jsonable")
)
- test_method_name = python_naming.method_name(
- Identifier(
- f"test_{numeric_place.cls.name}_{numeric_place.prop.name}_{what}"
+ if numeric_place.a_type is intermediate.PrimitiveType.FLOAT:
+ mutation, subscript = numeric_place_mutation(
+ numeric_place=numeric_place, zero_literal="0.0"
)
- )
+ expected_path = f".{numeric_place.prop.name}{subscript}"
- test_methods.append(
- Stripped(
- f"""\
+ test_method_name = python_naming.method_name(
+ Identifier(
+ f"test_{numeric_place.cls.name}_{numeric_place.prop.name}"
+ f"_non_finite"
+ )
+ )
+
+ test_methods.append(
+ Stripped(
+ f"""\
def {test_method_name}(self) -> None:
-{I}for value in {values_literal}:
+{I}for value in [math.inf, -math.inf, math.nan]:
{II}instance = our_jsonization.{from_jsonable}(
{III}_load_the_first_expected({model_type!r})
{II})
@@ -153,13 +180,91 @@ def {test_method_name}(self) -> None:
{III}{expected_path!r},
{III}context_manager.exception.path
{II})"""
+ )
+ )
+
+ if (
+ numeric_place.a_type is intermediate.PrimitiveType.INT
+ and numeric_place.index is None
+ ):
+ parsing_method_name = python_naming.method_name(
+ Identifier(f"test_{numeric_place.cls.name}_{numeric_place.prop.name}")
+ )
+
+ json_name_literal = python_common.string_literal(
+ numeric_place.prop.json_name
+ )
+
+ int_parsing_methods.append(
+ Stripped(
+ f"""\
+def {parsing_method_name}(self) -> None:
+{I}jsonable = _load_the_first_expected({model_type!r})
+
+{I}# NOTE (mristin):
+{I}# JSON has a single number type, so a whole float is an integer.
+{I}jsonable[{json_name_literal}] = 5.0
+{I}instance = our_jsonization.{from_jsonable}(jsonable)
+{I}self.assertIs(int, type(instance.{prop_name}))
+{I}self.assertEqual(5, instance.{prop_name})
+
+{I}for value, expected_cause in [
+{II}("5", "Expected an integer, but got: "),
+{II}(
+{III}1.5,
+{III}"Expected an integer, but got 1.5, which is not a whole number"
+{II}),
+{II}(
+{III}9007199254740992,
+{III}(
+{IIII}"Expected an integer, but got 9007199254740992, "
+{IIII}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"
+{III})
+{II}),
+{II}(
+{III}-9007199254740992.0,
+{III}(
+{IIII}"Expected an integer, but got -9007199254740992, "
+{IIII}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"
+{III})
+{II})
+{I}]:
+{II}jsonable[{json_name_literal}] = value
+
+{II}with self.assertRaises(
+{III}our_jsonization.DeserializationException
+{II}) as context_manager:
+{III}our_jsonization.{from_jsonable}(jsonable)
+
+{II}self.assertEqual(
+{III}expected_cause,
+{III}context_manager.exception.cause
+{II})"""
+ )
)
- )
- if len(test_methods) == 0:
+ if len(test_methods) == 0 and len(int_parsing_methods) == 0:
return Stripped("")
- body = "\n\n".join(test_methods)
+ blocks: List[str] = []
+
+ if len(test_methods) > 0:
+ body = "\n\n".join(test_methods)
+ blocks.append(
+ f"""\
+class TestSerializationFailures(unittest.TestCase):
+{I}{indent_but_first_line(body, I)}"""
+ )
+
+ if len(int_parsing_methods) > 0:
+ int_parsing_body = "\n\n".join(int_parsing_methods)
+ blocks.append(
+ f"""\
+class TestDeserializationOfIntegers(unittest.TestCase):
+{I}{indent_but_first_line(int_parsing_body, I)}"""
+ )
+
+ blocks_joined = "\n\n\n".join(blocks)
return Stripped(
f"""\
@@ -182,8 +287,7 @@ def _load_the_first_expected(model_type: str) -> Any:
{II}return json.load(fid)
-class TestSerializationFailures(unittest.TestCase):
-{I}{indent_but_first_line(body, I)}"""
+{blocks_joined}"""
)
diff --git a/aas_core_codegen/python/tests/_generate_test_safe_integers.py b/aas_core_codegen/python/tests/_generate_test_safe_integers.py
new file mode 100644
index 000000000..d26c6c284
--- /dev/null
+++ b/aas_core_codegen/python/tests/_generate_test_safe_integers.py
@@ -0,0 +1,359 @@
+"""Generate code to test that the integers are checked against the safe range."""
+
+import io
+from typing import List, Optional, Tuple
+
+from icontract import ensure
+
+from aas_core_codegen import intermediate, naming
+from aas_core_codegen.common import Stripped, Identifier, indent_but_first_line
+from aas_core_codegen.intermediate import uses as intermediate_uses
+from aas_core_codegen.python import common as python_common, naming as python_naming
+from aas_core_codegen.python.common import (
+ INDENT as I,
+ INDENT2 as II,
+ INDENT3 as III,
+ INDENT4 as IIII,
+)
+from aas_core_codegen.python.tests import (
+ _generate_test_jsonization_of_concrete_classes as python_tests_jsonization,
+)
+
+
+def _generate_predicate_test_case(integers: bool, json_types: bool) -> Stripped:
+ """Generate the tests of the predicate and of the messages of the common module."""
+ methods: List[Stripped] = [
+ Stripped(
+ f"""\
+def test_is_safe_integer(self) -> None:
+{I}for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]:
+{II}self.assertTrue(our_common.is_safe_integer(value), f"for {{value}}")
+
+{I}for value in [2 ** 53, -(2 ** 53), 10 ** 400]:
+{II}self.assertFalse(our_common.is_safe_integer(value), f"for {{value}}")"""
+ )
+ ]
+
+ if integers:
+ methods.append(
+ Stripped(
+ f"""\
+def test_unsafe_integer_message(self) -> None:
+{I}self.assertEqual(
+{II}"Expected an integer, but got 9007199254740992, "
+{II}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]",
+{II}our_common.unsafe_integer_message(2 ** 53)
+{I})"""
+ )
+ )
+
+ if json_types:
+ methods.append(
+ Stripped(
+ f"""\
+def test_unsafe_json_able_integer_message(self) -> None:
+{I}self.assertEqual(
+{II}"Expected a JSON-able value, but got the integer -9007199254740992, "
+{II}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]",
+{II}our_common.unsafe_json_able_integer_message(-(2 ** 53))
+{I})"""
+ )
+ )
+
+ body = "\n\n".join(methods)
+
+ return Stripped(
+ f"""\
+class TestPredicateAndMessages(unittest.TestCase):
+{I}{indent_but_first_line(body, I)}"""
+ )
+
+
+def _generate_place_tests(
+ cls: intermediate.ConcreteClass,
+ prop: intermediate.Property,
+ mutation: Stripped,
+ subscript: str,
+ cause_prefix: str,
+) -> List[Stripped]:
+ """
+ Generate the tests which put a value from outside at a place of an instance.
+
+ The unsafe values have to be reported with the same cause by the verification,
+ the JSON serialization and the XML serialization, while the safe values have
+ to pass all three.
+ """
+ model_type = naming.json_model_type(cls.name)
+ from_jsonable = python_naming.function_name(Identifier(f"{cls.name}_from_jsonable"))
+ prop_name = python_naming.property_name(prop.name)
+
+ # NOTE (mristin):
+ # The JSON serialization reports the path by the names of the properties in
+ # the meta-model, while the verification and the XML serialization by
+ # the Python names.
+ json_path = f".{prop.name}{subscript}"
+ python_path = f".{prop_name}{subscript}"
+
+ test_name_prefix = f"test_{cls.name}_{prop.name}"
+
+ return [
+ Stripped(
+ f"""\
+def {python_naming.method_name(Identifier(f"{test_name_prefix}_unsafe"))}(self) -> None:
+{I}for value in [2 ** 53, -(2 ** 53)]:
+{II}instance = our_jsonization.{from_jsonable}(
+{III}_load_the_first_expected({model_type!r})
+{II})
+
+{II}{indent_but_first_line(mutation, II)}
+
+{II}expected_cause = (
+{III}f"{cause_prefix}{{value}}, "
+{III}f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"
+{II})
+
+{II}self.assertIn(
+{III}({python_path!r}, expected_cause),
+{III}_paths_and_causes(our_verification.verify(instance))
+{II})
+
+{II}with self.assertRaises(
+{III}our_jsonization.SerializationException
+{II}) as json_context:
+{III}our_jsonization.to_jsonable(instance)
+
+{II}self.assertEqual(
+{III}({json_path!r}, expected_cause),
+{III}(json_context.exception.path, json_context.exception.cause)
+{II})
+
+{II}with self.assertRaises(
+{III}our_xmlization.SerializationException
+{II}) as xml_context:
+{III}our_xmlization.to_str(instance)
+
+{II}self.assertEqual(
+{III}({python_path!r}, expected_cause),
+{III}(xml_context.exception.path, xml_context.exception.cause)
+{II})"""
+ ),
+ Stripped(
+ f"""\
+def {python_naming.method_name(Identifier(f"{test_name_prefix}_safe"))}(self) -> None:
+{I}for value in [2 ** 53 - 1, -(2 ** 53 - 1)]:
+{II}instance = our_jsonization.{from_jsonable}(
+{III}_load_the_first_expected({model_type!r})
+{II})
+
+{II}{indent_but_first_line(mutation, II)}
+
+{II}# NOTE (mristin):
+{II}# The value might still violate an invariant, so we only check that
+{II}# the range is not reported.
+{II}self.assertListEqual(
+{III}[],
+{III}[
+{IIII}(path, cause)
+{IIII}for path, cause in _paths_and_causes(our_verification.verify(instance))
+{IIII}if "safe integer range" in cause
+{III}]
+{II})
+
+{II}our_jsonization.to_jsonable(instance)
+{II}our_xmlization.to_str(instance)"""
+ ),
+ ]
+
+
+def _json_able_mutation(
+ prop: intermediate.Property,
+) -> Optional[Tuple[Stripped, str]]:
+ """
+ Generate the statement which nests ``value`` in the JSON-able ``prop``.
+
+ Return the statement and the subscript which follows the property in the path,
+ or None if the property is not a required JSON-able one which we can simply set.
+ """
+ type_anno = prop.type_annotation
+ prop_name = python_naming.property_name(prop.name)
+
+ if isinstance(
+ type_anno,
+ (intermediate.JsonValueTypeAnnotation, intermediate.JsonArrayTypeAnnotation),
+ ):
+ return Stripped(f"instance.{prop_name} = [value]"), "[0]"
+
+ # NOTE (mristin):
+ # A constrained key might refuse any key that we come up with here.
+ if (
+ isinstance(type_anno, intermediate.JsonObjectTypeAnnotation)
+ and isinstance(type_anno.key, intermediate.PrimitiveTypeAnnotation)
+ and type_anno.key.a_type is intermediate.PrimitiveType.STR
+ ):
+ return Stripped(f'instance.{prop_name} = {{"a": value}}'), '["a"]'
+
+ return None
+
+
+@ensure(
+ lambda result: result.endswith("\n"),
+ "Trailing newline mandatory for valid end-of-files",
+)
+def generate(
+ symbol_table: intermediate.SymbolTable,
+ qualified_module_name: python_common.QualifiedModuleName,
+) -> str:
+ """
+ Generate code to test that the integers are checked against the safe range.
+
+ The same predicate decides in the verification, the JSON de/serialization and
+ the XML de/serialization, so that an instance which passes the verification
+ is guaranteed to be serializable.
+
+ The ``qualified_module_name`` indicates the fully-qualified name of the base module.
+ """
+ integers = intermediate_uses.integers(symbol_table)
+ json_types = intermediate_uses.json_types(symbol_table)
+
+ integer_methods: List[Stripped] = []
+ for numeric_place in intermediate.numeric_places(symbol_table):
+ if numeric_place.a_type is not intermediate.PrimitiveType.INT:
+ continue
+
+ mutation, subscript = python_tests_jsonization.numeric_place_mutation(
+ numeric_place=numeric_place, zero_literal="0"
+ )
+
+ methods = _generate_place_tests(
+ cls=numeric_place.cls,
+ prop=numeric_place.prop,
+ mutation=mutation,
+ subscript=subscript,
+ cause_prefix="Expected an integer, but got ",
+ )
+ integer_methods.extend(methods)
+
+ json_able_methods: List[Stripped] = []
+ if json_types:
+ for cls in symbol_table.concrete_classes:
+ for prop in cls.properties:
+ mutation_and_subscript = _json_able_mutation(prop)
+ if mutation_and_subscript is None:
+ continue
+
+ methods = _generate_place_tests(
+ cls=cls,
+ prop=prop,
+ mutation=mutation_and_subscript[0],
+ subscript=mutation_and_subscript[1],
+ cause_prefix="Expected a JSON-able value, but got the integer ",
+ )
+ json_able_methods.extend(methods)
+
+ has_places = len(integer_methods) > 0 or len(json_able_methods) > 0
+
+ imports = [f"import {qualified_module_name}.common as our_common"]
+ if has_places:
+ imports.extend(
+ [
+ f"import {qualified_module_name}.jsonization as our_jsonization",
+ f"import {qualified_module_name}.reporting as our_reporting",
+ f"import {qualified_module_name}.verification as our_verification",
+ ]
+ )
+ imports.append(f"import {qualified_module_name}.xmlization as our_xmlization")
+ imports_joined = "\n".join(imports)
+
+ paths_and_causes = f"""
+
+
+def _paths_and_causes(
+{II}errors: Iterable[our_reporting.Error]
+) -> List[Tuple[str, str]]:
+{I}return [(str(error.path), error.cause) for error in errors]"""
+
+ blocks: List[Stripped] = [
+ Stripped(
+ '''\
+"""Test that the integers are checked against the safe integer range."""'''
+ ),
+ python_common.WARNING,
+ ]
+
+ if has_places:
+ blocks.extend(
+ [
+ Stripped(
+ """\
+import json
+from typing import Any, Iterable, List, Tuple
+import unittest"""
+ ),
+ Stripped(imports_joined),
+ Stripped("import tests.common"),
+ Stripped(
+ f"""\
+def _load_the_first_expected(model_type: str) -> Any:
+{I}\"\"\"Load the first recorded example of the ``model_type``.\"\"\"
+{I}paths = sorted(
+{II}(
+{III}tests.common.TEST_DATA_DIR
+{III}/ "Json"
+{III}/ "Expected"
+{III}/ model_type
+{II}).glob("**/*.json")
+{I})
+
+{I}assert len(paths) > 0, (
+{II}f"Expected at least one recorded example of {{model_type}}, but got none"
+{I})
+
+{I}with paths[0].open("rt") as fid:
+{II}return json.load(fid){paths_and_causes}"""
+ ),
+ ]
+ )
+ else:
+ blocks.extend([Stripped("import unittest"), Stripped(imports_joined)])
+
+ blocks.append(
+ _generate_predicate_test_case(integers=integers, json_types=json_types)
+ )
+
+ for class_name, methods in (
+ ("TestIntegerProperties", integer_methods),
+ ("TestJsonAbleProperties", json_able_methods),
+ ):
+ if len(methods) == 0:
+ continue
+
+ body = "\n\n".join(methods)
+ blocks.append(
+ Stripped(
+ f"""\
+class {class_name}(unittest.TestCase):
+{I}{indent_but_first_line(body, I)}"""
+ )
+ )
+
+ blocks.extend(
+ [
+ Stripped(
+ f"""\
+if __name__ == "__main__":
+{I}unittest.main()"""
+ ),
+ python_common.WARNING,
+ ]
+ )
+
+ out = io.StringIO()
+ for i, block in enumerate(blocks):
+ if i > 0:
+ out.write("\n\n\n")
+
+ out.write(block)
+
+ out.write("\n")
+
+ return out.getvalue()
diff --git a/aas_core_codegen/python/tests/_generate_test_xml_rpc.py b/aas_core_codegen/python/tests/_generate_test_xml_rpc.py
index 04c9bf10d..5a292b26e 100644
--- a/aas_core_codegen/python/tests/_generate_test_xml_rpc.py
+++ b/aas_core_codegen/python/tests/_generate_test_xml_rpc.py
@@ -354,9 +354,9 @@ class TestWriting(unittest.TestCase):
{I}def test_an_integer_too_large_for_a_float_is_not_written(self) -> None:
{II}# NOTE (mristin):
{II}# A carries a JSON number, and a JSON number is a ``float``,
-{II}# so an ``int`` which no ``float`` can hold exactly has no
-{II}# to be written as.
-{II}for number in (2 ** 53 + 1, 10 ** 400):
+{II}# so an ``int`` outside the safe integer range [-2^53 + 1, 2^53 - 1] has
+{II}# no to be written as.
+{II}for number in (2 ** 53, -(2 ** 53), 10 ** 400):
{III}stream = io.StringIO()
{III}writer = our_xmlcommon.Writer(stream)
@@ -366,11 +366,21 @@ class TestWriting(unittest.TestCase):
{III}) as context:
{IIII}our_xmlrpc.write_discriminator(number, writer)
-{III}self.assertIn(
-{IIII}'not exactly representable as a JSON number',
+{III}self.assertEqual(
+{IIII}f'Expected a JSON-able value, but got the integer {{number}}, '
+{IIII}f'which is outside the safe integer range [-2^53 + 1, 2^53 - 1]',
{IIII}context.exception.cause
{III})
+{I}def test_a_safe_integer_is_written(self) -> None:
+{II}for number in (2 ** 53 - 1, -(2 ** 53 - 1)):
+{III}stream = io.StringIO()
+{III}writer = our_xmlcommon.Writer(stream)
+
+{III}our_xmlrpc.write_discriminator(number, writer)
+
+{III}self.assertIn(str(number), stream.getvalue())
+
{I}def test_a_key_which_is_no_string_stops_the_path(self) -> None:
{II}stream = io.StringIO()
{II}writer = our_xmlcommon.Writer(stream)
diff --git a/aas_core_codegen/python/tests/_generate_test_xmlization_of_concrete_classes.py b/aas_core_codegen/python/tests/_generate_test_xmlization_of_concrete_classes.py
index 874fa60be..4d9297f45 100644
--- a/aas_core_codegen/python/tests/_generate_test_xmlization_of_concrete_classes.py
+++ b/aas_core_codegen/python/tests/_generate_test_xmlization_of_concrete_classes.py
@@ -211,6 +211,41 @@ def {python_naming.method_name(
)
)
+ # NOTE (mristin):
+ # A valid ``xs:long`` outside of the safe integer range can not be
+ # serialized to JSON, so it is refused as in the JSON de-serialization.
+ blocks.append(
+ Stripped(
+ f"""\
+def {python_naming.method_name(
+ Identifier(f"test_{int_prop.name}_read_at_the_safe_bounds")
+ )}(self) -> None:
+{I}for value in [2 ** 53 - 1, -(2 ** 53 - 1)]:
+{II}instance = self._read_with({int_prop_xml_name!r}, str(value))
+{II}self.assertEqual(value, instance.{int_prop_name})"""
+ )
+ )
+
+ blocks.append(
+ Stripped(
+ f"""\
+def {python_naming.method_name(
+ Identifier(f"test_{int_prop.name}_read_outside_the_safe_range_fails")
+ )}(self) -> None:
+{I}for value in [2 ** 53, -(2 ** 53)]:
+{II}with self.assertRaises(
+{III}our_xmlization.DeserializationException
+{II}) as context_manager:
+{III}self._read_with({int_prop_xml_name!r}, str(value))
+
+{II}self.assertEqual(
+{III}f"Expected an integer, but got {{value}}, "
+{III}f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]",
+{III}context_manager.exception.cause
+{II})"""
+ )
+ )
+
body = "\n\n".join(blocks)
return (
diff --git a/aas_core_codegen/typescript/lib/_generate_common.py b/aas_core_codegen/typescript/lib/_generate_common.py
index f68fa7823..b261db57a 100644
--- a/aas_core_codegen/typescript/lib/_generate_common.py
+++ b/aas_core_codegen/typescript/lib/_generate_common.py
@@ -168,6 +168,9 @@
#: We deliberately do not use the native ``parseInt`` or ``Number``. They accept
#: much more than the other targets, *e.g.*, the white space, ``1e3`` or ``0x10``,
#: and silently lose the precision beyond ``Number.MAX_SAFE_INTEGER``.
+#:
+#: The other SDKs check the same safe range, though their integers could be
+#: larger, as the invariants must behave the same in all the SDKs.
PARSE_SAFE_INT = Stripped(
f"""\
/**
@@ -182,6 +185,10 @@
* `Number.MAX_SAFE_INTEGER`, which a double-precision floating-point number
* represents exactly.
*
+ * A `number` holds the integers exactly only within the safe range. The other
+ * SDKs check the same range, though their integers could be larger, so that
+ * the invariants which parse a text behave the same everywhere.
+ *
* @param text - to be parsed
* @returns the parsed integer
* @throws {{@link Error}} if `text` is not a safe integer
@@ -212,17 +219,16 @@
{II}const digit = code - 48;
{II}// NOTE (mristin):
-{II}// We check the range before we accumulate so that the arithmetic stays
-{II}// exact. The number 900719925474099 is Number.MAX_SAFE_INTEGER divided by 10,
-{II}// and 1 is its last digit.
-{II}if (value > 900719925474099 || (value === 900719925474099 && digit > 1)) {{
+{II}// While the result stays a safe integer, the arithmetic is exact. Once
+{II}// the exact result leaves the safe range, the rounded one leaves it as
+{II}// well, as 2^53 is representable, so we stop before we lose any precision.
+{II}value = value * 10 + digit;
+{II}if (!Number.isSafeInteger(value)) {{
{III}throw new Error(
{IIII}"Expected a safe integer, but got a text out of its range: " +
{IIIII}JSON.stringify(text)
{III});
{II}}}
-
-{II}value = value * 10 + digit;
{I}}}
{I}// NOTE (mristin):
@@ -231,6 +237,49 @@
}}"""
)
+#: Describe why a number is not an integer of the meta-model
+#:
+#: The predicates are the standard ``Number.isInteger`` and
+#: ``Number.isSafeInteger``. The messages are kept apart from them, and written
+#: only once, so that the de-serialization, the serialization and
+#: the verification report the very same errors.
+INTEGER_MESSAGES: Final[Sequence[Stripped]] = [
+ Stripped(
+ f"""\
+/**
+ * Describe that `value` is not a whole number, and thus not an integer.
+ *
+ * @param value - which is not a whole number
+ * @returns the error message
+ */
+export function notWholeIntegerMessage(value: number): string {{
+{I}return `Expected an integer, but got ${{value}}, which is not a whole number`;
+}}"""
+ ),
+ Stripped(
+ f"""\
+/**
+ * Describe that `value` is outside the safe integer range.
+ *
+ * @remarks
+ * The JSON de-serializers of the other languages read a number into a 64-bit
+ * floating-point number, which represents exactly only the integers in
+ * the safe integer range, see `Number.isSafeInteger`.
+ *
+ * @param value - which is a whole number outside the safe integer range, or
+ * its text if a `number` can not hold it exactly
+ * @returns the error message
+ */
+export function unsafeIntegerMessage(value: number | string): string {{
+{I}return (
+{II}`Expected an integer, but got ${{value}}, which is outside ` +
+{II}"the safe integer range [-2^53 + 1, 2^53 - 1]"
+{I});
+}}"""
+ ),
+]
+
+
# fmt: off
@ensure(
lambda result:
@@ -1022,6 +1071,13 @@ def generate(
if intermediate_uses.int_call(symbol_table):
blocks.insert(len(blocks) - 1, PARSE_SAFE_INT)
+ # NOTE (mristin):
+ # Analogous to the modulo, we add the messages only if an instance of
+ # the meta-model can hold an integer.
+ if intermediate_uses.integers(symbol_table):
+ for block in INTEGER_MESSAGES:
+ blocks.insert(len(blocks) - 1, block)
+
# NOTE (mristin):
# Analogous to the modulo, we add the helpers only if the meta-model computes
# an intersection or a difference of sets.
diff --git a/aas_core_codegen/typescript/lib/_generate_jsonization.py b/aas_core_codegen/typescript/lib/_generate_jsonization.py
index 35c8aa158..1ba50234a 100644
--- a/aas_core_codegen/typescript/lib/_generate_jsonization.py
+++ b/aas_core_codegen/typescript/lib/_generate_jsonization.py
@@ -471,18 +471,31 @@ def _generate_int_from_jsonable() -> Stripped:
): OurCommon.Either {{
{I}if (jsonable === null) {{
{II}return newDeserializationError(
-{III}"Expected an integer number, but got null"
+{III}"Expected an integer, but got: null"
{II});
{I}}}
{I}if (typeof jsonable !== "number") {{
{II}return newDeserializationError(
-{III}`Expected an integer number, but got: ${{typeof jsonable}}`
+{III}`Expected an integer, but got: ${{typeof jsonable}}`
{II});
{I}}}
+{I}// NOTE (mristin):
+{I}// ``Number.isInteger`` refuses an infinity and a not-a-number as well, and
+{I}// accepts a whole-valued literal such as ``1e2``.
{I}if (!Number.isInteger(jsonable)) {{
{II}return newDeserializationError(
-{III}`Expected an integer number, but got: ${{jsonable}}`
+{III}OurCommon.notWholeIntegerMessage(jsonable)
+{II});
+{I}}}
+
+{I}// NOTE (mristin):
+{I}// ``JSON.parse`` rounds a big integer literal to the nearest double, but
+{I}// the rounded value is still outside the safe integer range, so the check
+{I}// catches it.
+{I}if (!Number.isSafeInteger(jsonable)) {{
+{II}return newDeserializationError(
+{III}OurCommon.unsafeIntegerMessage(jsonable)
{II});
{I}}}
@@ -1722,7 +1735,7 @@ def _generate_json_able_helpers(
* @param jsonable - to be parsed
* @returns the parsed value, or an error
*/
-function jsonValueFromJsonable(
+export function jsonValueFromJsonable(
{I}jsonable: JsonValue
): OurCommon.Either {{
{I}if (jsonable === null) {{
@@ -1809,7 +1822,7 @@ def _generate_json_able_helpers(
* @param jsonable - to be parsed
* @returns the parsed array, or an error
*/
-function jsonArrayFromJsonable(
+export function jsonArrayFromJsonable(
{I}jsonable: JsonValue
): OurCommon.Either {{
{I}if (!Array.isArray(jsonable)) {{
@@ -1838,7 +1851,7 @@ def _generate_json_able_helpers(
* @param jsonable - to be parsed
* @returns the parsed object, or an error
*/
-function jsonObjectFromJsonable(
+export function jsonObjectFromJsonable(
{I}jsonable: JsonValue
): OurCommon.Either {{
{I}if (
@@ -1877,13 +1890,13 @@ def _generate_json_able_helpers(
* @returns `that`, as a value of a JSON document
* @throws {{@link SerializationError}} if `that` is not JSON-able
*/
-function jsonValueToJsonable(that: OurTypes.JsonArray): Array;
+export function jsonValueToJsonable(that: OurTypes.JsonArray): Array;
// NOTE (mristin):
// The copy keeps the shape of `that`, so we tell the compiler with the overloads.
// Otherwise, the result could not go into, *e.g.*, an `Array`.
-function jsonValueToJsonable(that: OurTypes.JsonObject): JsonObject;
-function jsonValueToJsonable(that: OurTypes.JsonValue): JsonValue;
-function jsonValueToJsonable(that: OurTypes.JsonValue): JsonValue {{
+export function jsonValueToJsonable(that: OurTypes.JsonObject): JsonObject;
+export function jsonValueToJsonable(that: OurTypes.JsonValue): JsonValue;
+export function jsonValueToJsonable(that: OurTypes.JsonValue): JsonValue {{
{I}if (that === null) {{
{II}return null;
{I}}}
@@ -2683,21 +2696,22 @@ def _generate_number_serializers(
/**
* Serialize `that` integer to a JSON-able value.
*
- * Only the integers in the range [-2^53 + 1, 2^53 - 1] are serialized. Outside
- * of it, an integer can not be exactly represented as a 64-bit floating-point
- * number, which is what the JSON de-serializers of the other languages read
- * a number into.
+ * Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are
+ * serialized. Outside of it, an integer can not be exactly represented as
+ * a 64-bit floating-point number, which is what the JSON de-serializers of
+ * the other languages read a number into. As a TypeScript `number` holds
+ * a fraction, an infinity and a not-a-number as well, we refuse them, too.
*
* @param that - integer to be serialized
* @returns `that`, unchanged
- * @throws {{@link SerializationError}} if outside the range
+ * @throws {{@link SerializationError}} if not a safe integer
*/
function integerToJsonable(that: number): number {{
-{I}if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) {{
-{II}throw new SerializationError(
-{III}`The integer can not be serialized to JSON as it is outside ` +
-{IIII}`the range [-2^53 + 1, 2^53 - 1]: ${{that}}`
-{II});
+{I}if (!Number.isInteger(that)) {{
+{II}throw new SerializationError(OurCommon.notWholeIntegerMessage(that));
+{I}}}
+{I}if (!Number.isSafeInteger(that)) {{
+{II}throw new SerializationError(OurCommon.unsafeIntegerMessage(that));
{I}}}
{I}return that;
}}"""
@@ -2995,7 +3009,11 @@ def generate(
else []
),
_generate_bool_from_jsonable(),
- _generate_int_from_jsonable(),
+ *(
+ [_generate_int_from_jsonable()]
+ if intermediate_uses.integers(symbol_table)
+ else []
+ ),
_generate_float_from_jsonable(),
_generate_str_from_jsonable(),
_generate_bytes_from_jsonable(),
diff --git a/aas_core_codegen/typescript/lib/_generate_verification.py b/aas_core_codegen/typescript/lib/_generate_verification.py
index f6fca060b..47dfe71ca 100644
--- a/aas_core_codegen/typescript/lib/_generate_verification.py
+++ b/aas_core_codegen/typescript/lib/_generate_verification.py
@@ -718,15 +718,23 @@ def _needs_verification(type_annotation: intermediate.TypeAnnotationUnion) -> bo
"""
Check whether a value of ``type_annotation`` has anything to verify at any depth.
- In TypeScript, we verify the constrained primitives, the instances of our classes
- and all the JSON-able values. We rely on the TypeScript compiler to spot invalid
- enumeration literals, so we do not verify the enumerations at all.
+ In TypeScript, we verify the integers, the constrained primitives, the instances
+ of our classes and all the JSON-able values. We rely on the TypeScript compiler
+ to spot invalid enumeration literals, so we do not verify the enumerations at all.
+
+ An integer is a TypeScript ``number``, so we verify that it is a whole number
+ in the safe integer range, which is exactly what its JSON serialization
+ requires.
"""
return any(
(
isinstance(type_anno, intermediate.OurTypeAnnotation)
and not isinstance(type_anno.our_type, intermediate.Enumeration)
)
+ or (
+ isinstance(type_anno, intermediate.PrimitiveTypeAnnotation)
+ and type_anno.a_type is intermediate.PrimitiveType.INT
+ )
or isinstance(
type_anno,
(
@@ -878,6 +886,13 @@ def _generate_verify_into(
)
function = "verify"
+ elif isinstance(type_anno, intermediate.PrimitiveTypeAnnotation):
+ assert type_anno.a_type is intermediate.PrimitiveType.INT, (
+ f"Unexpected primitive type with something to verify: "
+ f"{type_anno.a_type}"
+ )
+ function = "verifyInteger"
+
elif isinstance(type_anno, intermediate.JsonValueTypeAnnotation):
function = "verifyJsonValue"
@@ -1075,29 +1090,6 @@ def _generate_verify_property(prop: intermediate.Property) -> Optional[Stripped]
expr=f"that.{prop_name}", type_anno=type_anno, segments=[property_segment]
)
- # NOTE (mristin):
- # A JavaScript number does not distinguish between an integer and
- # a floating-point number, so we check the integer-ness of the constrained
- # primitives explicitly.
- if (
- isinstance(type_anno, intermediate.OurTypeAnnotation)
- and isinstance(type_anno.our_type, intermediate.ConstrainedPrimitive)
- and type_anno.our_type.constrainee is intermediate.PrimitiveType.INT
- ):
- block = Stripped(
- f"""\
-if (!Number.isInteger(that.{prop_name})) {{
-{I}const error = new VerificationError(
-{II}"Expected an integer, but got a floating-point number"
-{I});
-{I}error.path.prepend(
-{II}{indent_but_first_line(property_segment, II)}
-{I});
-{I}yield error;
-}}
-{block}"""
- )
-
if isinstance(prop.type_annotation, intermediate.OptionalTypeAnnotation):
block = Stripped(
f"""\
@@ -1149,8 +1141,8 @@ def _generate_transform_for_class(
# region Verify the properties
# NOTE (mristin):
- # The constrained primitives are not included in the recursion, so we verify
- # the properties of constrained primitives in the non-recursive part as well.
+ # The primitives, constrained or not, are not included in the recursion, so we
+ # verify the properties of primitives in the non-recursive part as well.
recurse_prop_blocks: List[Stripped] = []
for prop in cls.properties:
@@ -1159,7 +1151,7 @@ def _generate_transform_for_class(
continue
if (
- intermediate.try_constrained_primitive(
+ intermediate.try_primitive_type(
intermediate.beneath_optional(prop.type_annotation)
)
is not None
@@ -1459,6 +1451,21 @@ def _generate_verify_constrained_primitive(
if len(errors) > 0:
return None, errors
+ # NOTE (mristin):
+ # An integer is a TypeScript ``number``, so we check that it is a safe integer
+ # before the invariants, which assume one.
+ if constrained_primitive.constrainee is intermediate.PrimitiveType.INT:
+ blocks.insert(
+ 0,
+ Stripped(
+ f"""\
+if (!Number.isSafeInteger(that)) {{
+{I}yield* verifyInteger(that);
+{I}return;
+}}"""
+ ),
+ )
+
no_verification = False
if len(blocks) == 0:
blocks.append(Stripped("// There is no verification specified."))
@@ -1804,6 +1811,28 @@ def generate(
# noinspection PyTypeChecker
assert_never(verification)
+ if intermediate_uses.integers(symbol_table):
+ blocks.append(
+ Stripped(
+ f"""\
+/**
+ * Verify that `that` is a safe integer so that it can be serialized to JSON.
+ *
+ * @param that - to be verified
+ * @returns errors, if any
+ */
+export function *verifyInteger(
+{I}that: number
+): IterableIterator {{
+{I}if (!Number.isInteger(that)) {{
+{II}yield new VerificationError(OurCommon.notWholeIntegerMessage(that));
+{I}}} else if (!Number.isSafeInteger(that)) {{
+{II}yield new VerificationError(OurCommon.unsafeIntegerMessage(that));
+{I}}}
+}}"""
+ )
+ )
+
blocks.extend(_generate_verify_json_value(symbol_table=symbol_table))
observed_container_names: Set[Identifier] = set()
diff --git a/aas_core_codegen/typescript/lib/_generate_xmlization.py b/aas_core_codegen/typescript/lib/_generate_xmlization.py
index d415b37f4..de901ca5a 100644
--- a/aas_core_codegen/typescript/lib/_generate_xmlization.py
+++ b/aas_core_codegen/typescript/lib/_generate_xmlization.py
@@ -14,6 +14,7 @@
assert_never,
indent_but_first_line,
)
+from aas_core_codegen.intermediate import uses as intermediate_uses
from aas_core_codegen.typescript import (
common as typescript_common,
naming as typescript_naming,
@@ -83,17 +84,13 @@ def _generate_parse_content_for_primitive_type(
{I}// NOTE (mristin):
{I}// An integer is a ``number`` in TypeScript, and a ``number`` holds only
-{I}// the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds
-{I}// silently -- 9007199254740993 comes back as 9007199254740992, and
-{I}// 9223372036854775807 as 9223372036854776000, which is a perfectly
-{I}// well-formed but *different* xs:long. We refuse instead of corrupting.
-{I}//
-{I}// ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so
-{I}// a non-integer is refused here too.
+{I}// the safe integers exactly. Beyond them ``Number`` rounds silently --
+{I}// 9007199254740993 comes back as 9007199254740992, and 9223372036854775807
+{I}// as 9223372036854776000, which is a perfectly well-formed but *different*
+{I}// xs:long. We refuse instead of corrupting, and report the original text.
{I}if (!Number.isSafeInteger(value)) {{
{II}return newDeserializationError(
-{III}`Expected an integer within the safe range of a number, `
-{IIII}+ `but got: ${{text}}`
+{III}OurCommon.unsafeIntegerMessage(text)
{II});
{I}}}
@@ -1230,15 +1227,14 @@ def _generate_write_content_for_primitive_type(
{I}value: number
): void {{
{I}// NOTE (mristin):
-{I}// Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value
-{I}// we were handed has already lost its identity -- writing it out would
-{I}// record a different xs:long without a word. ``Number.isSafeInteger``
-{I}// covers ``Number.isInteger`` as well.
+{I}// Beyond the safe integers a ``number`` no longer holds every integer, so
+{I}// the value we were handed has already lost its identity -- writing it out
+{I}// would record a different xs:long without a word.
+{I}if (!Number.isInteger(value)) {{
+{II}throw new SerializationError(OurCommon.notWholeIntegerMessage(value));
+{I}}}
{I}if (!Number.isSafeInteger(value)) {{
-{II}throw new SerializationError(
-{III}`Expected an integer within the safe range of a number, `
-{IIII}+ `but got: ${{value}}`
-{II});
+{II}throw new SerializationError(OurCommon.unsafeIntegerMessage(value));
{I}}}
{I}parts.push(`${{value}}`);
@@ -2042,6 +2038,11 @@ def generate(
)
for primitive_type in intermediate.PrimitiveType:
+ if primitive_type is intermediate.PrimitiveType.INT and not (
+ intermediate_uses.integers(symbol_table)
+ ):
+ continue
+
blocks.append(_generate_parse_content_for_primitive_type(primitive_type))
for enumeration in symbol_table.enumerations:
@@ -2302,6 +2303,11 @@ def generate(
)
for primitive_type in intermediate.PrimitiveType:
+ if primitive_type is intermediate.PrimitiveType.INT and not (
+ intermediate_uses.integers(symbol_table)
+ ):
+ continue
+
blocks.append(_generate_write_content_for_primitive_type(primitive_type))
blocks.extend(
diff --git a/aas_core_codegen/typescript/tests/_generate_jsonization_concrete_classes_spec.py b/aas_core_codegen/typescript/tests/_generate_jsonization_concrete_classes_spec.py
index c77e345a8..9cd2726d9 100644
--- a/aas_core_codegen/typescript/tests/_generate_jsonization_concrete_classes_spec.py
+++ b/aas_core_codegen/typescript/tests/_generate_jsonization_concrete_classes_spec.py
@@ -6,7 +6,7 @@
from icontract import ensure
from aas_core_codegen import intermediate, naming
-from aas_core_codegen.common import Stripped, Identifier
+from aas_core_codegen.common import Stripped, Identifier, indent_but_first_line
from aas_core_codegen.typescript import (
common as typescript_common,
naming as typescript_naming,
@@ -23,18 +23,23 @@
def _generate_serialization_failure_tests(
symbol_table: intermediate.SymbolTable,
) -> List[Stripped]:
- """Generate the tests that a number unrepresentable in JSON is refused."""
+ """
+ Generate the tests that a number unrepresentable in JSON is refused.
+
+ An integer is refused with the very same message by the verification, and
+ the JSON and the XML serialization, so that a verified instance is always
+ serializable. We test the three together, and the safe integers at
+ the bounds of the range as the positive counterpart. The floating-point
+ numbers are not verified, so only their JSON serialization is tested.
+ """
result: List[Stripped] = []
+ has_integers = False
+
for numeric_place in intermediate.numeric_places(symbol_table):
- if numeric_place.a_type is intermediate.PrimitiveType.FLOAT:
- what = "a non-finite number"
- zero_literal = "0.0"
- values_literal = "[Infinity, -Infinity, NaN]"
- else:
- what = "an integer outside the range representable in JSON"
- zero_literal = "0"
- values_literal = "[9007199254740992, -9007199254740992]"
+ zero_literal = (
+ "0.0" if numeric_place.a_type is intermediate.PrimitiveType.FLOAT else "0"
+ )
prop_name = typescript_naming.property_name(numeric_place.prop.name)
@@ -82,17 +87,84 @@ def _generate_serialization_failure_tests(
Identifier(f"{numeric_place.cls.name}_from_jsonable")
)
+ load_instance = f"""\
+const instance = OurJsonization.{deserialization_function}(
+{I}loadTheFirstExpected({typescript_common.string_literal(cls_name_json)})
+).mustValue();"""
+
+ if numeric_place.a_type is intermediate.PrimitiveType.INT:
+ has_integers = True
+
+ result.append(
+ Stripped(
+ f"""\
+test(
+{I}"{numeric_place.cls.name} refuses an integer outside the safe integer range "
+{I}+ "at {expected_path} in verification and serialization",
+{I}() => {{
+{II}for (const value of [9007199254740992, -9007199254740992]) {{
+{III}{indent_but_first_line(load_instance, III)}
+
+{III}{mutation}
+
+{III}const expected =
+{IIII}`{expected_path}: Expected an integer, but got ${{value}}, ` +
+{IIII}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]";
+
+{III}expect(verificationErrorsOf(instance)).toContain(expected);
+{III}expect(
+{IIII}serializationErrorOf(() => OurJsonization.toJsonable(instance))
+{III}).toStrictEqual(expected);
+{III}expect(
+{IIII}serializationErrorOf(() => OurXmlization.toXmlString(instance))
+{III}).toStrictEqual(expected);
+{II}}}
+{I}}}
+);"""
+ )
+ )
+
+ result.append(
+ Stripped(
+ f"""\
+test(
+{I}"{numeric_place.cls.name} accepts the bounds of the safe integer range "
+{I}+ "at {expected_path} in verification and serialization",
+{I}() => {{
+{II}for (const value of [9007199254740991, -9007199254740991]) {{
+{III}{indent_but_first_line(load_instance, III)}
+
+{III}{mutation}
+
+{III}// NOTE (mristin):
+{III}// The invariants might still refuse the value, but not the range check.
+{III}expect(
+{IIII}verificationErrorsOf(instance).filter(
+{IIIII}(error) => error.includes("safe integer range")
+{IIII})
+{III}).toStrictEqual([]);
+{III}expect(
+{IIII}serializationErrorOf(() => OurJsonization.toJsonable(instance))
+{III}).toBeNull();
+{III}expect(
+{IIII}serializationErrorOf(() => OurXmlization.toXmlString(instance))
+{III}).toBeNull();
+{II}}}
+{I}}}
+);"""
+ )
+ )
+ continue
+
result.append(
Stripped(
f"""\
test(
-{I}"{numeric_place.cls.name} serialization fails on {what} "
+{I}"{numeric_place.cls.name} serialization fails on a non-finite number "
{I}+ "at {expected_path}",
{I}() => {{
-{II}for (const value of {values_literal}) {{
-{III}const instance = OurJsonization.{deserialization_function}(
-{IIII}loadTheFirstExpected({typescript_common.string_literal(cls_name_json)})
-{III}).mustValue();
+{II}for (const value of [Infinity, -Infinity, NaN]) {{
+{III}{indent_but_first_line(load_instance, III)}
{III}{mutation}
@@ -116,6 +188,43 @@ def _generate_serialization_failure_tests(
)
)
+ if has_integers:
+ result[0:0] = [
+ Stripped(
+ f"""\
+/**
+ * Verify `instance` and render the errors as `path: message`.
+ */
+function verificationErrorsOf(instance: OurTypes.Class): Array {{
+{I}return Array.from(OurVerification.verify(instance)).map(
+{II}(error) => `${{error.path}}: ${{error.message}}`
+{I});
+}}"""
+ ),
+ Stripped(
+ f"""\
+/**
+ * Run `serialize` and render its serialization error as `path: message`.
+ *
+ * @returns the rendered error, or `null` if `serialize` succeeded
+ */
+function serializationErrorOf(serialize: () => unknown): string | null {{
+{I}try {{
+{II}serialize();
+{I}}} catch (error) {{
+{II}if (
+{III}error instanceof OurJsonization.SerializationError
+{III}|| error instanceof OurXmlization.SerializationError
+{II}) {{
+{III}return `${{error.path}}: ${{error.message}}`;
+{II}}}
+{II}throw error;
+{I}}}
+{I}return null;
+}}"""
+ ),
+ ]
+
if len(result) > 0:
result.insert(
0,
@@ -160,6 +269,18 @@ def _generate_serialization_failure_tests(
# fmt: on
def generate(symbol_table: intermediate.SymbolTable) -> str:
"""Generate code to test the JSON de/serialization of concrete classes."""
+ # NOTE (mristin):
+ # The tests of the integers serialize to XML as well, see
+ # :py:func:`_generate_serialization_failure_tests`.
+ xmlization_import = (
+ 'import * as OurXmlization from "../src/xmlization";\n'
+ if any(
+ numeric_place.a_type is intermediate.PrimitiveType.INT
+ for numeric_place in intermediate.numeric_places(symbol_table)
+ )
+ else ""
+ )
+
blocks: List[Stripped] = [
Stripped(
"""\
@@ -169,14 +290,14 @@ def generate(symbol_table: intermediate.SymbolTable) -> str:
),
typescript_common.WARNING,
Stripped(
- """\
+ f"""\
import * as fs from "fs";
import * as path from "path";
import * as OurJsonization from "../src/jsonization";
import * as OurTypes from "../src/types";
import * as OurVerification from "../src/verification";
-
+{xmlization_import}
import * as TestCommon from "./common";"""
),
Stripped(
diff --git a/aas_core_codegen/typescript/tests/_generate_xmlization_concrete_classes_spec.py b/aas_core_codegen/typescript/tests/_generate_xmlization_concrete_classes_spec.py
index 89961208e..7e017fce2 100644
--- a/aas_core_codegen/typescript/tests/_generate_xmlization_concrete_classes_spec.py
+++ b/aas_core_codegen/typescript/tests/_generate_xmlization_concrete_classes_spec.py
@@ -64,7 +64,13 @@
def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stripped]:
- """Generate the tests over the lexical forms which no example can hold."""
+ """
+ Generate the tests over the lexical forms which no example can hold.
+
+ The integers at and just beyond the bounds of the safe integer range can not
+ be recorded in an example either, as the example would have to hold every
+ one of them, so we test them here, too.
+ """
cls = intermediate.first_class_of_only_required_primitives(symbol_table)
if cls is None:
return []
@@ -84,7 +90,9 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
if a_type in prop_by_a_type
]
- if len(relevant) == 0:
+ int_prop = prop_by_a_type.get(intermediate.PrimitiveType.INT)
+
+ if len(relevant) == 0 and int_prop is None:
return []
cls_name_typescript = typescript_naming.class_name(cls.name)
@@ -98,7 +106,10 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
* Read the first recorded example of {cls_name_typescript} with the content
* of the element `xmlName` replaced by `text`.
*/
-function readWith(xmlName: string, text: string): OurTypes.{cls_name_typescript} {{
+function readEitherWith(
+{I}xmlName: string,
+{I}text: string
+): ReturnType {{
{I}const pths = Array.from(
{II}TestCommon.findFilesBySuffixRecursively(
{III}path.join(
@@ -121,7 +132,17 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
{I}const patched = original.slice(0, start) + text + original.slice(end);
-{I}const instanceOrError = OurXmlization.fromXmlString(patched);
+{I}return OurXmlization.fromXmlString(patched);
+}}"""
+ ),
+ Stripped(
+ f"""\
+/**
+ * Read the first recorded example of {cls_name_typescript} with the content
+ * of the element `xmlName` replaced by `text`, and expect no error.
+ */
+function readWith(xmlName: string, text: string): OurTypes.{cls_name_typescript} {{
+{I}const instanceOrError = readEitherWith(xmlName, text);
{I}expect(instanceOrError.error).toBeNull();
{I}const casted = OurTypes.{as_function}(instanceOrError.mustValue());
@@ -131,7 +152,7 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
{I}return casted;
}}"""
- )
+ ),
]
for a_type, prop in relevant:
@@ -159,6 +180,38 @@ def _generate_lexical_tests(symbol_table: intermediate.SymbolTable) -> List[Stri
)
)
+ if int_prop is not None:
+ prop_xml_name = naming.xml_property(int_prop.name)
+ prop_xml_name_literal = typescript_common.string_literal(prop_xml_name)
+ prop_name = typescript_naming.property_name(int_prop.name)
+
+ result.append(
+ Stripped(
+ f"""\
+test({typescript_common.string_literal(f"{prop_xml_name} read at the bounds of the safe integer range")}, () => {{
+{I}for (const text of ["9007199254740991", "-9007199254740991"]) {{
+{II}const instance = readWith({prop_xml_name_literal}, text);
+{II}expect(instance.{prop_name}).toBe(Number(text));
+{I}}}
+}});"""
+ )
+ )
+
+ result.append(
+ Stripped(
+ f"""\
+test({typescript_common.string_literal(f"{prop_xml_name} refused outside the safe integer range")}, () => {{
+{I}for (const text of ["9007199254740992", "-9007199254740992"]) {{
+{II}const instanceOrError = readEitherWith({prop_xml_name_literal}, text);
+{II}expect(instanceOrError.error?.message).toStrictEqual(
+{III}`Expected an integer, but got ${{text}}, ` +
+{IIII}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"
+{II});
+{I}}}
+}});"""
+ )
+ )
+
return result
diff --git a/dev/test_data/common_meta_models/int_length_interplay.py b/dev/test_data/common_meta_models/int_length_interplay.py
index 253f359da..1e10e964c 100644
--- a/dev/test_data/common_meta_models/int_length_interplay.py
+++ b/dev/test_data/common_meta_models/int_length_interplay.py
@@ -42,7 +42,20 @@ def no_number_is_seven(numbers: Sequence[int], count: int) -> bool:
@verification
def no_seven_from(numbers: Sequence[int], start: int) -> bool:
- """Check the ranges over the integers with a length as a bound."""
+ """
+ Check the ranges over the integers with a length as a bound.
+
+ The ``start`` is checked against the bounds before it is used as an index,
+ so that the invariant holds up even against integers as large as
+ ``2 ** 53``. A negative ``start`` is refused, while a ``start`` beyond
+ the numbers leaves nothing to check.
+ """
+ if start < 0:
+ return False
+
+ if start >= len(numbers):
+ return True
+
for i in range(start, len(numbers)):
number = numbers[i]
if number == 7:
diff --git a/dev/test_data/intermediate/common_meta_models/int_length_interplay/expected_symbol_table.txt b/dev/test_data/intermediate/common_meta_models/int_length_interplay/expected_symbol_table.txt
index f192069d1..d36d4fb35 100644
--- a/dev/test_data/intermediate/common_meta_models/int_length_interplay/expected_symbol_table.txt
+++ b/dev/test_data/intermediate/common_meta_models/int_length_interplay/expected_symbol_table.txt
@@ -646,7 +646,12 @@ SymbolTable(
parsed=...),
description=DescriptionOfSignature(
summary='Check the ranges over the integers with a length as a bound.',
- remarks=[],
+ remarks=[
+ textwrap.dedent("""\
+ The start is checked against the bounds before it is used as an index,
+ so that the invariant holds up even against integers as large as
+ 2 ** 53. A negative start is refused, while a start beyond
+ the numbers leaves nothing to check.""")],
arguments_by_name=[],
returns=None,
parsed=...),
diff --git a/dev/test_data/live_tests/common_test_data/primitive_types/Json/Unexpected/Unserializable/ValueViolation/Something/int_above_the_safe_range.json b/dev/test_data/live_tests/common_test_data/primitive_types/Json/Unexpected/Unserializable/ValueViolation/Something/int_above_the_safe_range.json
new file mode 100644
index 000000000..efeea4e64
--- /dev/null
+++ b/dev/test_data/live_tests/common_test_data/primitive_types/Json/Unexpected/Unserializable/ValueViolation/Something/int_above_the_safe_range.json
@@ -0,0 +1,7 @@
+{
+ "someBool": true,
+ "someInt": 9007199254740992,
+ "someFloat": 34.23,
+ "someString": "hello world",
+ "someBytes": "SGVsbG8gV29ybGQ="
+}
diff --git a/dev/test_data/live_tests/common_test_data/primitive_types/Json/Unexpected/Unserializable/ValueViolation/Something/int_below_the_safe_range.json b/dev/test_data/live_tests/common_test_data/primitive_types/Json/Unexpected/Unserializable/ValueViolation/Something/int_below_the_safe_range.json
new file mode 100644
index 000000000..77912934e
--- /dev/null
+++ b/dev/test_data/live_tests/common_test_data/primitive_types/Json/Unexpected/Unserializable/ValueViolation/Something/int_below_the_safe_range.json
@@ -0,0 +1,7 @@
+{
+ "someBool": true,
+ "someInt": -9007199254740992,
+ "someFloat": 34.23,
+ "someString": "hello world",
+ "someBytes": "SGVsbG8gV29ybGQ="
+}
diff --git a/dev/test_data/live_tests/common_test_data/primitive_types/Xml/Unexpected/Unserializable/ValueViolation/something/int_above_the_safe_range.xml b/dev/test_data/live_tests/common_test_data/primitive_types/Xml/Unexpected/Unserializable/ValueViolation/something/int_above_the_safe_range.xml
new file mode 100644
index 000000000..78349d91f
--- /dev/null
+++ b/dev/test_data/live_tests/common_test_data/primitive_types/Xml/Unexpected/Unserializable/ValueViolation/something/int_above_the_safe_range.xml
@@ -0,0 +1 @@
+true900719925474099234.23hello worldSGVsbG8gV29ybGQ=
\ No newline at end of file
diff --git a/dev/test_data/live_tests/common_test_data/primitive_types/Xml/Unexpected/Unserializable/ValueViolation/something/int_below_the_safe_range.xml b/dev/test_data/live_tests/common_test_data/primitive_types/Xml/Unexpected/Unserializable/ValueViolation/something/int_below_the_safe_range.xml
new file mode 100644
index 000000000..63304a18b
--- /dev/null
+++ b/dev/test_data/live_tests/common_test_data/primitive_types/Xml/Unexpected/Unserializable/ValueViolation/something/int_below_the_safe_range.xml
@@ -0,0 +1 @@
+true-900719925474099234.23hello worldSGVsbG8gV29ybGQ=
\ No newline at end of file
diff --git a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/jsonization.cpp
index 6a2753c99..afe059f43 100644
--- a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/jsonization.cpp
+++ b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/jsonization.cpp
@@ -303,110 +303,6 @@ std::pair<
);
}
-std::pair<
- common::optional,
- common::optional
-> DeserializeInt64(
- const nlohmann::json& json
-) {
- if (!json.is_number()) {
- std::wstring message = common::Concat(
- L"Expected an integer number, but got a value of type: ",
- common::Utf8ToWstring(json.type_name())
- );
-
- return std::make_pair<
- common::optional,
- common::optional
- >(
- common::nullopt,
- common::make_optional(
- message
- )
- );
- }
-
- static_assert(
- std::is_same::value,
- "Expected nlohmann::json::number_integer_t to equal int64_t, "
- "but it does not."
- );
-
- if (json.is_number_integer()) {
- return std::make_pair<
- common::optional,
- common::optional
- >(
- json.get(),
- common::nullopt
- );
- }
-
- if (json.is_number_unsigned()) {
- std::wstring message = common::Concat(
- L"Expected a 64-bit integer number, "
- L"but got an unsigned integer number which does not fit in that range: ",
- std::to_wstring(json.get())
- );
-
- return std::make_pair<
- common::optional,
- common::optional
- >(
- common::nullopt,
- common::make_optional(
- message
- )
- );
- }
-
- // NOTE (mristin):
- // We have to check that the number is an integer even though it can
- // not be stored in int64_t in order to give an informative message.
-
- const nlohmann::json::number_float_t number(
- json.get()
- );
-
- nlohmann::json::number_float_t integer_part;
- const bool is_integer(
- std::modf(number, &integer_part) == 0
- );
- if (is_integer) {
- std::wstring message = common::Concat(
- L"Expected a 64-bit integer number, "
- L"but got an integer number which does not fit in that range: ",
- std::to_wstring(number)
- );
-
- return std::make_pair<
- common::optional,
- common::optional
- >(
- common::nullopt,
- common::make_optional(
- message
- )
- );
- } else {
- std::wstring message = common::Concat(
- L"Expected a 64-bit integer number, "
- L"but got a non-integer number: ",
- std::to_wstring(number)
- );
-
- return std::make_pair<
- common::optional,
- common::optional
- >(
- common::nullopt,
- common::make_optional(
- message
- )
- );
- }
-}
-
std::pair<
common::optional,
common::optional
@@ -14894,48 +14790,6 @@ nlohmann::json SerializeBool(
return value;
}
-/**
- * \brief Serialize the given number to a JSON value.
- *
- * We verify that the integer is within the range representable by 64-bit floats
- * for interoperability with other de-serializers.
- */
-std::pair<
- common::optional,
- common::optional
-> SerializeInt64(int64_t value) {
- if (
- value < -9007199254740991L
- || value > 9007199254740991L
- ) {
- const std::wstring message = common::Concat(
- L"The integer ",
- std::to_wstring(value),
- L" can not be serialized to JSON "
- L"as it is outside the range [-2^53 + 1, 2^53 - 1] and can not "
- L"be exactly represented as a 64-bit floating point number."
- );
-
- return std::make_pair<
- common::optional,
- common::optional
- >(
- common::nullopt,
- common::make_optional(
- message
- )
- );
- }
-
- return std::make_pair<
- common::optional,
- common::optional
- >(
- common::make_optional(value),
- common::nullopt
- );
-}
-
/**
* \brief Serialize the given floating-point number to a JSON value.
*
diff --git a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/verification.cpp
index c01ca7356..233086cb7 100644
--- a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/verification.cpp
+++ b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/verification.cpp
@@ -7630,8 +7630,8 @@ std::unique_ptr NewNestedVerificator(
const void*
) {
// NOTE (mristin):
- // The meta-model uses no JSON-able values, so no value needs a nested
- // verification.
+ // The meta-model uses neither integers nor JSON-able values, so no value
+ // needs a nested verification.
return nullptr;
}
diff --git a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/xmlization.cpp
index 24323bfdf..8a2d5743b 100644
--- a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/xmlization.cpp
+++ b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/src/xmlization.cpp
@@ -3703,117 +3703,6 @@ std::pair<
);
}
-std::pair<
- common::optional,
- common::optional
-> DeserializeInt64(
- xml_common::ReaderMergingText& reader
-) {
- #ifdef DEBUG
- if (reader.node().kind() == xml_common::NodeKind::Error) {
- throw std::logic_error(
- "Unexpected unhandled XML error in DeserializeInt64. "
- "DeserializeInt64 expects no error node."
- );
- }
- #endif
-
- if (reader.node().kind() != xml_common::NodeKind::Text) {
- return NoInstanceAndDeserializationErrorWithCause(
- common::Concat(
- L"Expected to parse an xs:long from XML text, but got ",
- xml_common::NodeToHumanReadableWstring(reader.node())
- )
- );
- }
-
- const std::string text(
- xml_common::CollapseWhitespace(
- static_cast< // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)
- const xml_common::TextNode&
- >(reader.node()).text
- )
- );
-
- // NOTE (mristin):
- // The lexical form has to be checked before the text is parsed. The std::sto*
- // family reads far more than XSD admits: it stops at the first character it
- // can not use and answers with what it has read so far, so "0x10" comes out
- // as 0, "1abc" as 1, and "5.0" as 5.
- if (!xml_common::MatchesXsLongNumeral(text)) {
- return NoInstanceAndDeserializationErrorWithCause